12 Commits
Author SHA1 Message Date
hulk 955e56efef docs(SerialMate): 勘误 v0.1.2 发布备注——1234 静默非实例故障, 是本机网络路径对 1234 的选择性干扰(异机 SSH 认证正常/22,443 本机正常) 2026-10-16 19:37:09 +08:00
hulk 72b7aee372 docs(agent): 记录 SerialMate v0.1.2 已发布(tag 指向 8e91e66, 附件 63.6 MB 上传核对);当日实例 SSH 1234 不通, push 改走 HTTPS+token 2026-10-16 19:23:44 +08:00
hulk 8e91e661f1 fix(SerialMate): v0.1.2 解析模式不再吞数据——不成帧字节透传显示
背景: 用户接非 DLPilot 协议设备(COM25)开解析模式接收区一片空白——_dispatch_rx 在解析模式只上抛完整合法帧, 不成帧字节(纯文本日志/别的协议/波特率不符乱码)被 FrameParser 重同步时静默丢弃。用户明确定位: 首先是基础串口调试工具, 自定义协议解析是附加项, 附加能力不得影响本体功能。

修复: protocol.py FrameParser 丢弃字节累积进 garbage(新增 drain_garbage(), feed() 返回值不变); link.py 新增 on_garbage(data, ts) 回调, 每批无条件 drain(未接回调也不累积); main.py 把 on_garbage 并入 raw 事件与透传行同格式显示, 解析协议帧开关补 tooltip, 版本 0.1.2。半帧前缀扣留语义有意保留(可能补全成真帧, 至多扣一个前缀长度)。

自检: selftest 8b +10 项(纯文本全透出/噪声+帧+尾字节/坏CRC帧字节全透出/半帧头扣留补全), loop 3b +3 项, gui 5b +2 项; 全量 67+21+68 项 PASS; exe 已重建。README/AGENTS.md(不变量 5a) 同步。
2026-10-16 19:08:55 +08:00
hulk ac76c10132 docs(DLPilot): 面向二供的使用说明入库——使用说明.md 从解压安装包写起(安装/硬件连接/界面总览与快速开始/左列HDMI源端/右列光机控制/命令参考/典型流程/数据判读/FAQ/版本沿革),§3 嵌标注版主界面截图(manual-img/)并配四条上手要点;拓扑表述按勘误校准:HDMI 与 RS485 同接转接板、GD32E230 为板上主控 MCU(逻辑控制),不用 GD32 网关说法;记录转接板上电默认开光机(§3/§7.1);PDF 不入库、本地保留(暂存时排除,md 可随时重出);AGENTS.md 进度登记 2026-10-14 11:18:00 +08:00
hulk 7ba01eae5e chore: 全仓约束 exe 不入库——.gitignore 全局挡 *.exe,历史 39 个 exe blob 已用 git filter-repo 清除(仓库 1.1 GiB -> ~1 MB);各文档发布约定同步;agent.md 哈希引用按重写后映射更新 2026-10-13 16:31:45 +08:00
hulk 42ff258b35 feat: I2C-Lab v0.1.0 代码入库——RP2040 PIO 被动监控 probe 固件(pico-sdk C, 250MHz 双核) + Fluent 上位机(事务流/波形快照/CSV/演示模式);release/ 交付物(exe/UF2/BIN/zip)不入库、本地保留待真机验收发布;根 README 工具表与 agent.md 登记;I2C-Inject README 标注后续并入 I2C-Lab;移除误登记的 OBI-1/ui-style-demo 相关内容 2026-10-13 15:41:29 +08:00
hulk 74469cfe66 feat: DLPilot 校准测试图案(CeramixNano 系列 6 张)入库——assets 存档+release 平铺+自定义栏位接入(预置配置);package_release 收集规则同步扩展 2026-10-13 14:09:32 +08:00
hulk 2a9a5f5b96 docs(agent): 记录 DLPilot v0.4.3 与 SerialMate v0.1.1 已发布(tag 指向 296e220 / e5b2e45);踩坑表新增 PySide6 6.11 实例级枚举访问条目 2026-10-13 11:46:44 +08:00
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
59 changed files with 4404 additions and 129 deletions
+10 -1
View File
@@ -2,11 +2,15 @@
__pycache__/ __pycache__/
*.pyc *.pyc
# PyInstaller 工作目录(打包产物 exe 已直接入库, 构建中间物不入库) # PyInstaller 工作目录(构建中间物不入库)
build/ build/
dist/ dist/
*.spec *.spec
# exe 一律不入库(2026-10-13 起): 交付物只走 Gitea Release 附件, 本地保留。
# 历史 39 个 exe blob 已用 git filter-repo 清除(仓库 1.1 GiB -> ~1 MB (pack 实测))
*.exe
# 发布 zip: 可由 src/package_release.py 重新生成, 只上传 Gitea Release # 发布 zip: 可由 src/package_release.py 重新生成, 只上传 Gitea Release
DLPilot/release/*.zip DLPilot/release/*.zip
EDID-viewer/release/*.zip EDID-viewer/release/*.zip
@@ -22,5 +26,10 @@ SerialMate/release/serial_mate.json
SerialMate/src/board_profiles.json SerialMate/src/board_profiles.json
SerialMate/release/board_profiles.json SerialMate/release/board_profiles.json
# I2C-Lab 运行态配置不入库; 固件构建产物在 firmware/build/(build/ 规则已覆盖)
# release/ 交付物(exe/UF2/BIN/zip)不入库: 本地保留, 待真机验收发布时再走流水线
I2C-Lab/src/i2c_lab.json
I2C-Lab/release/
# ZCode 客户端本地会话数据(计划留档等), 非项目配置 # ZCode 客户端本地会话数据(计划留档等), 非项目配置
.zcode/ .zcode/
+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
+39 -3
View File
@@ -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 文件名) |
@@ -143,8 +145,9 @@ python publish_gitea.py ../release/DLPilot-v<版本>-win64.zip --replace --insec
发布约定: 发布约定:
- tag/release 命名 `DLPilot-vX.Y.Z`,zip 命名 `DLPilot-vX.Y.Z-win64.zip`(publish_gitea.py - tag/release 命名 `DLPilot-vX.Y.Z`,zip 命名 `DLPilot-vX.Y.Z-win64.zip`(publish_gitea.py
依赖此命名从文件名推 tag); 依赖此命名从文件名推 tag);
- **exe、pattern_slots.json 与 release/ 下平铺的图案 png 改动跟随源码/资产生成提交入库; - **pattern_slots.json 与 release/ 下平铺的图案 png 改动跟随源码/资产生成提交入库;
zip 永不入库**(.gitignore 已挡); exe 不入库**(2026-10-13 起全仓 `.gitignore` 挡 `*.exe`,交付物只走 Gitea
Release 附件,本地保留);**zip 永不入库**(.gitignore 已挡);
- publish_gitea.py 幂等:release 已存在则复用,同名资产默认跳过,`--replace` 删旧重传; - publish_gitea.py 幂等:release 已存在则复用,同名资产默认跳过,`--replace` 删旧重传;
- token 读环境变量 `GITEA_TOKEN`:本机已用 setx 持久化为用户级变量(新开的会话/终端自动 - token 读环境变量 `GITEA_TOKEN`:本机已用 setx 持久化为用户级变量(新开的会话/终端自动
继承,值不入库;在 setx 之前启动的进程读不到,需重开终端),急用时也可临时传 `--token`; 继承,值不入库;在 setx 之前启动的进程读不到,需重开终端),急用时也可临时传 `--token`;
@@ -174,6 +177,8 @@ python publish_gitea.py ../release/DLPilot-v<版本>-win64.zip --replace --insec
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 selftest_theme.py` —— 主题切换回归(改 app_fluent.py 涉及样式/主题后必跑;
离屏渲染,Dark/Light 往返后校验 swatch 底色样式与渲染像素、标题字号); 离屏渲染,Dark/Light 往返后校验 swatch 底色样式与渲染像素、标题字号);
3b. `python selftest_log.py` —— 收发日志上限/队列消费回归(改 `_dlpc_log`/`_poll_dlpc`
后必跑;600 行封顶 500、坏条目不中断 drain);
4. `python hdmi_gui.py --seconds 5` —— GUI 冒烟(自动开关); 4. `python hdmi_gui.py --seconds 5` —— GUI 冒烟(自动开关);
5. `python hdmi_gui.py --test-pattern Checker --area 800x600 --seconds 3` —— 投图链路; 5. `python hdmi_gui.py --test-pattern Checker --area 800x600 --seconds 3` —— 投图链路;
6. `python hdmi_gui.py --dump` —— EDID 解算不抛异常; 6. `python hdmi_gui.py --dump` —— EDID 解算不抛异常;
@@ -229,4 +234,35 @@ python publish_gitea.py ../release/DLPilot-v<版本>-win64.zip --replace --insec
位于电流行与预设行之间(命令直发 `_dlpc_send`,无新增协议逻辑)。 位于电流行与预设行之间(命令直发 `_dlpc_send`,无新增协议逻辑)。
验证:compileall / selftest_rs485 / selftest_theme ALL PASS;离屏渲染断言"清空" 验证:compileall / selftest_rs485 / selftest_theme ALL PASS;离屏渲染断言"清空"
与标题垂直中心差 0px、7 个快捷按钮齐全;真实桌面冒烟 exit 0。APP_VERSION → 0.4.2 与标题垂直中心差 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)与设备
拔出混判,一次转换器停滞即断链误报"设备拔出"。
- 2026-10-14 新增面向二供的独立使用手册 `使用说明.md` + `使用说明.pdf`(基于
README 与 v0.4.3 Release 编写,从解压安装包写起的操作级文档,含命令参考/典型
流程/FAQ/版本沿革;不随发布 zip 分发,单独发给供应商)。PDF 由 md 生成:
python-markdown → Chrome headless 矢量打印 → pymupdf 回填目录页码/页脚/元数据,
两遍编译(第一遍定位各章节页码,第二遍回填目录),构建脚本为一次性临时产物
未入库;后续改 md 后需按同流程重出 PDF。文档拓扑表述已按用户勘误校准:
HDMI 与 RS485 接同一块转接板,GD32E230 是板上主控 MCU(逻辑控制),无独立
"GD32 网关"说法(README/根 README/本文其他处旧措辞待同步)。§3 界面总览含
标注版主界面截图(`manual-img/ui-overview-quickstart.png`,手册配图目录)与
四条快速开始要点;已记录"转接板上电默认开光机"新事实(§3/§7.1)。现 11 页。
+1 -1
View File
@@ -218,7 +218,7 @@ DLPilot/
│ ├── bw_*.png ×5 # 640x360 黑白系列 │ ├── bw_*.png ×5 # 640x360 黑白系列
│ └── solid_*.png ×10 # 800x600 纯色系列 │ └── solid_*.png ×10 # 800x600 纯色系列
├── release/ # 构建与发布产物(机器生成区, 不手改) ├── release/ # 构建与发布产物(机器生成区, 不手改)
│ ├── DLPilot.exe # 打包单文件版(改动跟随源码提交入库) │ ├── DLPilot.exe # 打包单文件版(不入库,本地保留;随发布上传 Gitea Release)
│ ├── pattern_slots.json # exe 运行配置(栏位预置 + 串口记忆) │ ├── pattern_slots.json # exe 运行配置(栏位预置 + 串口记忆)
│ ├── *.png / README.md # 附属文件平铺副本(由 package_release.py 从 assets/ 同步) │ ├── *.png / README.md # 附属文件平铺副本(由 package_release.py 从 assets/ 同步)
│ └── *.zip # 发布包(不入库, 上传 Gitea Release) │ └── *.zip # 发布包(不入库, 上传 Gitea Release)
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+12 -12
View File
@@ -6,40 +6,40 @@
"file": "bw_star.png" "file": "bw_star.png"
}, },
{ {
"name": "BW_ZONE", "name": "black",
"file": "bw_zoneplate.png" "file": "CeramixNano-black.png"
}, },
{ {
"name": "BW_TEST", "name": "BW_TEST",
"file": "bw_testcard.png" "file": "bw_testcard.png"
}, },
{ {
"name": "自定义4", "name": "calipoint",
"file": null "file": "CeramixNano-caliPoint.png"
}, },
{ {
"name": "自定义5", "name": "自定义5",
"file": null "file": null
}, },
{ {
"name": "自定义6", "name": "circle_640P",
"file": null "file": "CeramixNano-circle_640p.png"
}, },
{ {
"name": "自定义7", "name": "自定义7",
"file": null "file": null
}, },
{ {
"name": "自定义8", "name": "contrast",
"file": null "file": "CeramixNano-contrast.png"
}, },
{ {
"name": "自定义9", "name": "white",
"file": null "file": "CeramixNano-White.png"
}, },
{ {
"name": "自定义10", "name": "sharpness",
"file": null "file": "CeramixNano-Sharpness_XY_3.png"
} }
], ],
"serial": { "serial": {
+15 -6
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
@@ -63,7 +64,7 @@ import edid_parse
import patterns import patterns
APP_NAME = "DLPilot" APP_NAME = "DLPilot"
APP_VERSION = "0.4.2" APP_VERSION = "0.4.3"
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"
@@ -440,6 +441,10 @@ class App(QMainWindow):
trailing=self._button("清空", self._dlpc_clear)) trailing=self._button("清空", self._dlpc_clear))
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)
@@ -755,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()
@@ -766,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()
+2 -1
View File
@@ -31,7 +31,8 @@ BASE = os.path.dirname(SRC)
RELEASE = os.path.join(BASE, 'release') RELEASE = os.path.join(BASE, 'release')
ASSETS = os.path.join(BASE, 'assets') ASSETS = os.path.join(BASE, 'assets')
PATTERN_GLOBS = ('bw_*.png', 'solid_*.png') # CeramixNano*.png: 校准图案素材(用户提供, 二进制直存 assets/, 无生成脚本)
PATTERN_GLOBS = ('bw_*.png', 'solid_*.png', 'CeramixNano*.png')
def detect_version(): def detect_version():
+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())
+393
View File
@@ -0,0 +1,393 @@
# DLPilot 使用说明
**HDMI 源端调试 + 光机控制工具**
| 项目 | 说明 |
|---|---|
| 适用版本 | v0.4.3(2026-10-13 发布) |
| 适用系统 | Windows 10 / 11 x64,免安装、无需 Python |
| 适用对象 | LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板的调试、测试与校准人员 |
| 文档日期 | 2026-10-14 |
DLPilot 是配套投影转接板调试的 PC 端工具,一台电脑同时完成两件事:
- **HDMI 源端**:查看板卡上报的 EDID、切换输出分辨率、向板卡全屏投放测试图案
(纯色、黑白测试图、自定义校准图);
- **光机控制**:通过 USB-RS485 控制光机——开关光机、设光强、查温度/电流、
PD 电源自检与功率读数、TEC 温控。
界面上左右两列分别对应这两个功能域,互不干扰,可以只用其中一列。
---
[TOC]
---
## 1. 安装(从压缩包开始)
### 1.1 拿到安装包
安装包为 `DLPilot-v0.4.3-win64.zip`(约 67 MB),由我方对接工程师提供。
收到后请先核对文件名中的版本号,本文档对应 **v0.4.3**;若版本更新,以随包说明为准。
### 1.2 解压
1. 在电脑上选择一个**可写**的本地目录,例如 `D:\Tools\`(不要解压到只读目录、
网络共享盘或需要管理员权限才能写入的系统目录,原因见 1.5 节);
2. 右键压缩包 →「全部解压」→ 目标路径填 `D:\Tools\`,确认后完成解压;
3. 解压后得到一个 `DLPilot` 文件夹。
> **注意:不要在压缩包预览窗口里直接双击运行 exe。**
> 直接从压缩包内运行时程序被解压到临时目录,你配置的自定义图片和串口参数
> 会写到临时目录并随其清空而丢失。务必先完整解压,再运行解压后的 exe。
解压后的文件夹内容如下:
| 文件 | 作用 |
|---|---|
| `DLPilot.exe` | 主程序,双击运行(单文件自包含,光机控制所需串口库已内置) |
| `pattern_slots.json` | 运行配置:自定义图案栏位、测试卡区域、串口参数记忆。**删除它会丢失你的全部本地配置** |
| `bw_*.png`(5 张) | 640×360 黑白测试图:西门子星、频率扫描条纹、渐变棋盘、波带片、综合测试卡 |
| `solid_*.png`(10 张) | 800×600 纯色测试图(R/G/B 各 55/AA/FF + 三原色),供自定义栏位加载 |
| `CeramixNano-*.png`(如包含) | 光机校准图案(black / white / contrast / circle_640p / caliPoint / sharpness),新版本安装包已预置到自定义栏位 |
| `README.md` | 随包说明(与本文档内容同源) |
### 1.3 首次启动
双击 `DLPilot.exe`:
- 首次启动比之后慢一两秒(单文件程序需要先自解压),属正常现象;
- 程序没有数字签名,首次运行 Windows SmartScreen 可能弹出
「Windows 已保护你的电脑」——点「更多信息」→「仍要运行」即可;
- 个别杀毒软件可能对加壳的单文件 exe 误报,如遇拦截请放行或将安装目录加入白名单。
程序启动后出现双栏主界面(见第 3 节),即安装成功。
### 1.4 升级新版本
1. 关闭正在运行的 DLPilot;
2. 解压新版本压缩包到**新的空目录**(不要直接覆盖旧目录,避免新旧文件混杂);
3. 如需保留旧版的自定义栏位与串口记忆:把旧目录里的 `pattern_slots.json`
和你自己添加的图片文件复制到新目录,覆盖同名文件即可;
4. 双击新目录的 `DLPilot.exe`。
### 1.5 为什么必须放在可写目录
程序的所有本地数据(`pattern_slots.json` 配置、你添加的自定义图片)都保存在
**exe 所在目录**,不写注册表、不写用户目录。放在只读目录会导致配置保存失败。
两份不同目录的安装(比如两台工位电脑、或同一台电脑解压了两份)各自独立保存配置,互不影响。
## 2. 硬件连接
### 2.1 整体链路
```
PC(DLPilot) ──HDMI──→ LT8619C(HDMI→RGB888)──RGB888 视频──→ DLPC3421 光机
PC(DLPilot) ──USB-RS485──→ GD32E230 主控 ───I2C 控制(0x1B)──→ DLPC3421 光机
```
HDMI 与 RS485 接的是**同一块转接板**:LT8619C 负责把 HDMI 视频转换为 RGB888;
GD32E230 是板上的主控 MCU,负责整板逻辑控制——DLPilot 的 RS485 命令由它解析,
再经 I2C 总线控制光机(DLPC3421)。
### 2.2 HDMI 连接
1. 用 HDMI 线把电脑显卡输出接到转接板 HDMI 输入;
2. 板卡正常上电。板卡 shadow EDID 只声明 640×360@60 一种模式;
3. 如果 Windows 没有自动点亮这块"显示器"(显示设置里看不到):
启动 DLPilot,工具检测到是本系列板卡(EDID 厂商签名)接入但未进桌面时,
会自动把它以扩展屏方式拉回桌面;仍不行时在 Windows「显示设置」里手动改为「扩展这些显示器」。
### 2.3 RS485 连接(光机控制)
1. USB-RS485 转换器接电脑 USB,其 A/B 端子对应接转接板的 RS485 A/B 端子;
2. 串口参数固定 **115200 波特率、8 数据位、无校验、1 停止位(115200 8N1)**,
软件默认值即为此,一般无需修改;
3. 板卡供电建议使用满足 **PD 12V / ≥3A** 的电源适配器——软件的"电源自检"
就是以该挡位为正常判据(见 5.3 节)。
### 2.4 上电时序(重要)
DLPC3421 光机芯片启动需要几秒钟。**上电后立即发开光机/设光强/查电流命令会收到
拒绝应答(0xFF),这是正常保护,不是故障**——稍等几秒待光机就绪后再发。
## 3. 界面总览
主窗口为双栏仪表盘,顶栏可切换 Dark / Light / System 外观(跟随系统为默认),
顶栏右侧「关于」按钮内有软件版本信息:
| 左列(HDMI 源端) | 右列(光机控制 RS485) |
|---|---|
| 显示器选择:显示器下拉 + 刷新 | 串口选择:串口下拉 + 波特率等参数 + 连接 |
| 支持的模式(默认折叠):分辨率下拉 + 应用 / 恢复 + 时序明细 | 电源检查:电源自检 + 功率检测 |
| EDID 信息(默认折叠):解算结果一览 | 串口数据:收发日志(译码 + hex)+ 清空 |
| 测试图案:内置图案 / R·G·B 色值 / 自定义与校准栏位 / 测试卡区域选择 | 光机命令:快捷按钮排 + 预设下拉 + 自定义输入 + 温度读取 |
| EDID 原始数据:hex 显示 + 逐字节解析 | 快捷滑条:光强 / 定时 / TEC 温度 |
![DLPilot 主界面(红框标注为快速开始要点)](manual-img/ui-overview-quickstart.png)
*图 1 主界面与快速开始要点*
红框与编号对应四个上手要点:
1. **选显示器**:左上「显示器选择」卡的下拉框选中接转接板的显示器
(Generic PnP Monitor),下方会显示当前实际输出分辨率/刷新率;
多屏或热插拔后点「刷新」重新枚举;
2. **投测试图案**:现在的转接板**上电默认打开光机**,无需先连串口——
在「测试图案」卡选好测试卡区域后即可直接投放指定图案
(点「停止」按钮或按 ESC 退出全屏);
3. **连串口**:需要控制光机光强、查温度/电流等才需要串口——USB-RS485
转换器接好后,在「串口」卡选口(参数保持默认 115200 8N1)点「连接」;
4. **控光机**:常用光机控制都集中在「光机命令」卡(开/关光机、查版本、
软件重启、TEC、温度读取、预设下拉与自定义输入),下方「快捷滑条」
可便捷调光强、定时投光、TEC 温度。
「支持的模式」「EDID 信息」两张卡片默认折叠,点卡片标题展开。
左列只操作显卡输出(不碰光机),右列只操作光机(不碰显卡)。
## 4. HDMI 源端操作(左列)
### 4.1 选择显示器
1. 左上下拉框列出当前所有显示器,选中**接转接板的那一块**;
多屏时注意核对型号/分辨率,不要选成你的主屏;
2. 热插拔后列表没刷新时点「刷新」重新枚举;
3. 下拉框内容与系统识别一致;如对序号有疑问,可在 Windows「显示设置」中
点「识别」核对。
### 4.2 查看支持的模式 / 切换分辨率
1. 展开「支持的模式」卡片,下拉框列出显卡按该屏 EDID 提供的全部模式
(板卡正常时可见 640×360@60);
2. 选中模式后点「应用」即切换。**切换是临时的**:不写注册表,重启电脑或
重新插拔即还原,不会永久改动你的桌面设置;
3. 测试完成后点「恢复」,回到本次打开软件时的原始模式;
4. 卡片同时显示选中模式的时序明细,可核对像素时钟、刷新率等。
### 4.3 查看 EDID
- 「EDID 信息」卡片:显示从该显示器读到的 EDID 解算结果——厂商、型号、
尺寸、Gamma、输入类型、支持的全部模式、首选时序完整参数、校验和;
- 「EDID 原始数据」卡片:显示 128 字节(含扩展块)原始 hex;
点右上「逐字节解析」按钮,可按偏移逐字段展开基础块与 CTA-861 扩展块,
每个字段附原始字节,适合核对板卡 EDID 是否按规格上报。
### 4.4 投放测试图案
1. 先在「测试卡区域」选择投放区域:
- `640x360`(默认)——与光机面板原生分辨率 1:1,**链路验证/校准用这个**;
- `800x600`——对应 demo 板首选时序;
- `full`——铺满所选显示器的整个屏幕;
2. 点「测试图案」卡片中的按钮即全屏投放到所选显示器:
- 内置图案:纯红、纯绿、纯蓝、黑白棋盘格、网格线(程序直接绘制);
- R/G/B 三行 `55 / AA / FF` 色值按钮:按通道着色的纯色,用途见 7.2 节;
- 自定义/校准图片栏位:见 4.5 节;
3. 投放新图案会自动替换当前旧图案;按 **ESC** 键或点「停止测试图像」退出全屏。
### 4.5 自定义图片栏位(含校准图案)
「测试图案」卡片提供 **10 个可配置栏位**,每个栏位一个图案按钮 + 一个齿轮按钮:
1. 点某个栏位的**齿轮**按钮,弹出配置对话框;
2. 填写栏位名称(显示在按钮上),选择图片文件(推荐 PNG;JPG/BMP/WebP
依运行环境而定,PNG 最稳妥);
3. 保存后图片自动**复制到 exe 所在目录**,栏位按钮即可点按投放。
因此不要事后手动移动/删除 exe 旁的图片文件,否则栏位失效;
4. 安装包已预置常用栏位(黑白测试图与校准图案,具体以包内为准),
可按上述步骤改成你自己的图。
栏位配置、测试卡区域选择会自动保存在 exe 旁的 `pattern_slots.json`。
## 5. 光机控制操作(右列)
### 5.1 连接串口
1. 「串口选择」卡片:下拉框列出所有串口,显示为 `COMx + 设备描述`。
多路 USB 转换器(如 CH344 四路)每一路描述不同(serial-A/B/C/D),
据此选口;描述相同时软件自动附物理位置区分;
2. 波特率等参数默认 115200 8N1,与板卡固件约定一致,保持默认即可;
3. 点「连接」。连接失败会给出简短原因(串口被占用/不存在等),
完整错误信息进收发日志;
4. 上次使用的串口与参数会被记住,下次启动自动带出(预置配置带的串口如与
你现场不符,第一次使用时改成你的实际串口即可)。
### 5.2 收发日志
「串口数据 · 收发日志」卡片实时显示:
- `→` 发出的命令;
- `←` 收到的应答:**可读译码 + 原始 hex** 对照显示;
- 错误自动附释义,例如 `err:3(I2C NACK)` 表示光机侧 I2C 无应答;
- 点「清空」清屏。日志超过 500 行自动滚动淘汰旧条目,长时间联机不会卡顿。
协议帧格式(判读 hex 时参考):命令帧 `D5 01 LEN <ASCII命令> CRC`,
应答帧 `B5 TYPE LEN <payload> CRC`,CRC 为自第 2 字节起的累加和低 8 位。
### 5.3 电源检查
「电源检查」卡片有两个按钮:
- **电源自检**:自动依次查询供电协议(M301)→ PD 挡位最大电流(M302)→
实测母线电压(M303),并按实测电压就近判定 PD 固定挡(5/9/12/15/20/28V):
| 显示 | 含义 |
|---|---|
| 绿底,如 `FPDO: 12V@3A` | 供电正常:12V 挡且电流 ≥3A |
| 红底,如 `FPDO: 9V@3A` | 挡位不符:电源没有协商到 12V 挡,请换符合要求的 PD 电源 |
| `ERR` | 实测电压不落在任何标准 PD 挡内,检查供电 |
说明:取电芯片的电压配置寄存器只写不可读,挡位只能靠实测电压判定,
所以以自检结果为准,而不是电源标称。
- **功率检测**:自动依次读母线电压(M303)/ 负载电流(M304)/ 负载功率(M305),
读数框显示 电压(V)、电流(mA)、功率(W)。功率量程 0~295 W。
### 5.4 光机命令
「光机命令」卡片从上到下:
1. **快捷按钮排**(最常用操作一键完成):
`开光机`、`关光机`、`查版本`、`软件重启`、`开TEC`、`关TEC`、`TEC33.0°C`;
2. **温度读取**:`查DMD温度`、`查LAMP温度`,应答数值(0.1°C 单位换算后)
实时填入按钮旁的显示框;
3. **预设下拉**:常用命令清单,选中即发送;
4. **自定义输入框**:手动输入任意命令(见第 6 节命令表),**回车即发送**。
### 5.5 快捷滑条
「快捷滑条」卡片三个滑条,拖到目标值后点对应按钮下发:
- **光强**:拖动后点「下发电流值」(有效范围 54–800,RGB 三路同值);
- **定时**:设定毫秒数后点「开光机(定时)」——开光机并在到时后自动关,
0 表示不限时;上限 32700 ms;
- **TEC 温度**:滑条/输入框设定凝露点 200–350(对应 20.0–35.0°C),
点「下发温度值」。
滑条旁的数值输入框支持直接键入,回车后自动限制到有效范围并与滑条同步。
## 6. 光机命令参考
| 命令 | 功能 | 应答示例 / 说明 |
|---|---|---|
| `M1` | 协议握手 | `ok` |
| `M730S0` / `M730S1` | 开 / 关光机 | `ok` |
| `M730S0T2000` | 开光机并定时 2000ms 自动关 | `ok`;定时 ≤32700 ms |
| `M731S100` | 设光强(54–800,RGB 同值) | `ok`;失败回 `err:R..G..B..` |
| `M732` | 查三路电流设置值 | `okR110G110B110` |
| `M737` | 查投光是否结束 | 投光进行中会**延迟应答**,属正常 |
| `M201` / `M202` | 查 DMD / LAMP 温度 | `okTL:246` = 24.6°C |
| `M7S0` / `M7S1` | TEC 自动控温 **开** / 关 | 注意 **S0 是开**,方向与直觉相反 |
| `M8S330` | TEC 凝露点 33.0°C | 范围 200–350,超范围回 `err:OL` |
| `M301` | 查供电协议(PD/QC3.0/EPR…) | ASCII 文本 |
| `M302` | 查 PD 挡位最大电流 | mA(50mA/LSB 已换算) |
| `M303` | 查母线电压(实测) | 如 12020 mV = 12.02 V |
| `M304` | 查负载电流(实测) | mA,带符号(正=正向) |
| `M305` | 查负载功率(实测) | mW,软件换算显示为 W |
| `M999` | 查固件版本 | 如 `okv20.0.0.1` |
| `M888` | 软件重启 | `ok` 后约 100ms 复位 |
> **注意:**
>
> 1. 光机上电后几秒内处于启动期,此时 `M730/M731/M732` 会被就绪门禁拒绝(回 0xFF),
> 等几秒再发即可;
> 2. USB-RS485 转换器是半双工的:本机发送期间板上回包不会回环显示,属正常现象;
> 3. 设光强等带数值的命令,超范围值会被板卡拒绝,请按表中范围下发。
## 7. 典型操作流程
### 7.1 首次整链路验证(新板卡点灯)
```
1. HDMI 接转接板,USB-RS485 接板卡 A/B,板卡上电(PD 12V/≥3A),等数秒
2. 启动 DLPilot → 「刷新」→ 显示器下拉选中板卡(640x360@60)
3. 光机控制:选串口 → 连接 → 点「查版本」(M999)
应答 okv... 即 RS485→光机 链路通;无应答见第 9 节排查
4. 点「电源自检」:绿底 FPDO: 12V@3A 即供电合格
5. 测试卡区域选 640x360(与光机面板原生 1:1);转接板目前上电默认开光机,
可直接投图,若光机已被关闭再点「开光机」
6. 投放测试图案目视检查 → 设光强/查温度电流(按需)
7. 测完:「关光机」→ 分辨率「恢复」→ 停止测试图像
```
### 7.2 RGB 通道与位序检查
用「测试图案」里的 R/G/B 行色值按钮(`55 / AA / FF` 三档):
1. **纯红/纯绿/纯蓝**:验证 RGB 三通道映射是否正确
(硬件约定 R=D[23:16]、G=D[15:8]、B=D[7:0],投纯红时仅 R 通道应有输出);
2. **55 与 AA 配对**:`0x55`(01010101)与 `0xAA`(10101010)互为字节镜像,
专用于检出位序颠倒——位交换设置(0x606E)正确时图像应为 50% 灰阶占空比;
3. **FF**:全高基准,配合电流读数核对满幅输出。
### 7.3 校准图案投放
1. 「测试卡区域」选 `640x360`(校准图按光机原生分辨率制作);
2. 安装包已预置校准栏位(black / white / contrast / circle_640p / caliPoint /
sharpness,以包内为准);如需换用自己的校准图,按 4.5 节齿轮按钮配置;
3. 依次点栏位按钮投放,按校准规程判读(新图案自动替换旧图案,无需先停止);
4. 完成后 ESC 退出。
## 8. 数据判读速查
| 现象/读数 | 判读 |
|---|---|
| 开机后发命令回 `0xFF` | 光机启动中,就绪门禁拒绝,等几秒重发 |
| 日志出现 `err:3(I2C NACK)` | 光机侧 I2C 无应答:光机未上电/未就绪,或板卡故障 |
| 温度应答 `okTL:246` | 24.6°C(0.1°C 单位) |
| 电流应答 `okR110G110B110` | R/G/B 三路电流设置值均为 110 |
| 电源自检红底 `FPDO: 9V@3A` | PD 只协商到 9V 挡,未到 12V,更换/检查电源与线材 |
| 定时投光中 M737 无立即应答 | 正常:投光结束才应答,勿反复重发 |
## 9. 常见问题(FAQ)
**Q1:双击 exe 没反应 / 被 Windows 拦截。**
SmartScreen 弹窗点「更多信息」→「仍要运行」;杀毒软件误报时放行或加白名单;
仍不行则确认解压完整(exe 约 67 MB)。
**Q2:光机命令发出去没反应。**
按顺序排查:① 串口选对没有(115200 8N1)、A/B 线接对没有;② 先点「查版本」
(M999)验证链路,M999 都不通就是串口/接线问题;③ M999 通但开光机回 0xFF——
光机启动中,等几秒重试;④ 半双工转换器发送期间看不到回环是正常的。
**Q3:显示器下拉里找不到板卡。**
检查 HDMI 线与板卡供电;点「刷新」;启动 DLPilot 会自动把本系列板卡拉回扩展桌面,
仍不行到 Windows 显示设置手动改「扩展这些显示器」,然后回软件刷新。
**Q4:切了分辨率,桌面布局乱了。**
软件的切换是临时的:点「恢复」立即还原;重启电脑或重插 HDMI 也会还原。
不会永久改动系统设置。
**Q5:自定义栏位点了没图。**
该栏位未配置图片(点齿轮配置);或 exe 旁的图片文件被移动/删除——重新配置一次,
图片会重新复制到 exe 旁。
**Q6:串口下拉里好几个 COM 口,选哪个?**
下拉项带设备描述(如 USB-SERIAL CH340 / serial-A~D),选你的 USB-RS485
转换器对应的口;描述相同的会附物理位置。拿不准就拔插一次看哪个口出现/消失。
**Q7:两台电脑/两个目录各解压了一份,配置会串吗?**
不会。配置保存在各自 exe 旁的 `pattern_slots.json`,互相独立。
**Q8:软件用久了变卡。**
v0.4.2 及更早版本存在长会话卡顿问题,v0.4.3 已修复;请确认用的是 v0.4.3
(顶栏「关于」可查版本),旧版本请升级。
**Q9:换电脑要带走哪些东西?**
整个安装目录打包即可;至少要 `DLPilot.exe` + `pattern_slots.json`(含你的栏位
与串口记忆)+ 你添加的自定义图片。
## 10. 版本沿革
| 版本 | 日期 | 要点 |
|---|---|---|
| v0.4.3(当前) | 2026-10-13 | 修复长会话越用越卡、命令点击无响应的问题(收发日志积压);新增日志回归自检 |
| v0.4.2 | 2026-09-28 | 收发日志「清空」按钮归位;恢复光机快捷按钮排(开/关光机、查版本、TEC 等) |
| v0.4.1 | 2026-09-28 | 修复切换 Light/Dark 主题后色值按钮底色丢失;串口连接失败改为简短提示,不再撑破窗口 |
| v0.4.0 | 2026-09-24 | 界面重写为双栏仪表盘(Fluent 风格),功能基线版本 |
## 附录:技术支持
遇到本文未覆盖的问题,请联系我方对接工程师反馈:
软件版本号(顶栏「关于」)、操作步骤、收发日志截图(含原始 hex),
光机问题请同时提供「查版本」(M999)与温度读数。
+1 -1
View File
@@ -21,7 +21,7 @@ Windows 10/11 显示器 EDID 查看器。使用 **PySide6 / PySide6-Fluent-Widge
- `src/` — 人写代码:界面(main.py)、EDID 解析(edid.py)、Windows 读取(windows_edid.py)、主题(theme.py)、解析测试(test_edid.py)和打包流水线。 - `src/` — 人写代码:界面(main.py)、EDID 解析(edid.py)、Windows 读取(windows_edid.py)、主题(theme.py)、解析测试(test_edid.py)和打包流水线。
- `assets/` — 图标与生成脚本(可再生)。 - `assets/` — 图标与生成脚本(可再生)。
- `release/` — 交付物:`EDID-Viewer.exe` 入库;发布 zip 只上传 Gitea Release,不入库。 - `release/` — 交付物:`EDID-Viewer.exe` 本地保留不入库;发布 zip 只上传 Gitea Release,不入库。
- `AGENTS.md` — 工程约定与任务边界(改代码前必读);`VALIDATION.md` — 验证记录。 - `AGENTS.md` — 工程约定与任务边界(改代码前必读);`VALIDATION.md` — 验证记录。
## 环境与构建 ## 环境与构建
+8 -3
View File
@@ -1,8 +1,13 @@
# I2C-Inject — LT8619C 寄存器择机注入工具 # I2C-Inject — LT8619C 寄存器择机注入工具
> **⏸ 状态:暂停开发(2026-09-25)。** 工具与固件保留在仓库中不作删除,当前没有继续开发的计划。 > **⏸ 状态:暂停开发(2026-09-25),后续开发并入 I2C-Lab(2026-10-02)。**
> demo 板寄存器语义实测当时即由本工具完成;0x606E 其余位交换经确认(2026-09-25)现状正常、 > 工具与固件保留在仓库中不作删除;本目录转为留档。完整的 I2C 调试台
> 符合预期,无需单独实测。恢复开发前请勿视为完整可用。 > (纯被动监控 → 主机读写/择机注入 → 从机模拟)在 [`I2C-Lab/`](../I2C-Lab/)
> 继续:监控阶段 v0.1 已就绪(PIO 硬件捕获 + 实时事务流 + 波形快照),
> 本工具的"择机注入"职能将在其主机阶段复活(接线 GP8/SDA、GP9/SCL 与
> `=`/`!` 回显约定不变)。
> demo 板寄存器语义实测当时即由本工具完成;0x606E 其余位交换经确认(2026-09-25)
> 现状正常、符合预期,无需单独实测。恢复开发前请勿视为完整可用。
> 更新:2026-09-03。由两部分组成:**RP2040 注入器固件**(`mcu-rp2040/`,刷入 > 更新:2026-09-03。由两部分组成:**RP2040 注入器固件**(`mcu-rp2040/`,刷入
> BPI-Pico-RP2040,MicroPython)+ **PC 端 GUI**(`i2c_inject_gui.py` / 打包版 > BPI-Pico-RP2040,MicroPython)+ **PC 端 GUI**(`i2c_inject_gui.py` / 打包版
+99
View File
@@ -0,0 +1,99 @@
# I2C-Lab AGENTS.md — 工程约束与开发进度
> 面向 Agent/开发者的工程档案。使用说明见 `README.md`; 仓库整体进度见根
> `agent.md`。遵循全仓约定: 三区目录(src 人写 / assets 可再生 / release 交付)、
> Python 3.14 + PySide6-Fluent-Widgets、纯标准库协议模块、PyInstaller onefile。
## 1. 项目约定
- **定位**: I2C 总线监控调试台(2026-10-02 启动, 原 §3.5 三合一需求)。
第一阶段 = 纯被动监控; 主机/从机为后续阶段(见 README §7)。
- **与 I2C-Inject 关系**: I2C-Inject 保持"暂停开发, 留档备查"状态不复活;
本工具是新开的独立目录, 吸收其资产(接线规矩 GP8/9、`=`/`!` 回显约定、
解码算法、图标母题), 主机阶段落地后取代其"注入"职能。
- **解码语义权威是 `src/i2cdec.py`**(算法源自 I2C-Inject `i2c_sniff_decode.py`
真机验证版本): 固件 `main.c` 的解码状态机与它的 `decode_edges()` 必须语义
一致; T/W 行语法以 `to_tline()/parse_tline()/parse_wline()` 为准, 改任一侧
必须同步另一侧并补 selftest 向量。
- **时间戳约定**: 单位 us(int/u32); t0=START 沿, t1=收尾沿(STOP/rep-START/
最后边沿); 边沿 pins 编码 bit0=SDA bit1=SCL(GP8/GP9)。
- **固件架构**: 单 PIO SM `bus_watch`(变沿即压 FIFO, `push noblock`) +
核1 取时间戳并跑解码状态机(与 GUI 解码同语义) + 核0 USB CDC 命令行。
250MHz/vreg 1.15V。修改引脚/波特/协议时注意 `README.md` §5 与 GUI 同步。
- **pyserial 是唯一第三方运行依赖**(缺失降级提示); GUI/解码全标准库+PySide6。
- **release/ 交付物不入库**(2026-10-13 起全仓统一: `.gitignore` 全局挡 `*.exe`):
exe/UF2/BIN/zip 留本地, 待真机验收正式发布时再走流水线(`.gitignore` 已挡
`I2C-Lab/release/`)。
## 2. 构建
### PC 端
```powershell
cd src
python selftest.py ; python selftest_gui.py # 改 i2cdec/main 后必跑
python main.py --seconds 5 # GUI 冒烟
python main.py --seconds 5 --demo # 演示模式冒烟(无硬件)
python build_exe.py ; python package_release.py 0.1.0
python publish_gitea.py ../release/I2C-Lab-v0.1.0-win64.zip --insecure
```
### 固件(见 README §6)
pico-sdk 2.3.1 + tinyusb(锁定提交) + Arm GNU 13.3 + CMake/Ninja;
UF2 用 `make_uf2.py`(**不要**加 `pico_add_extra_outputs`, 会拉 picotool)。
## 3. 开发进度
### v0.1.0(2026-10-02, 未发布——待真机验收)
- 固件: bus_watch.pio(8 指令边沿捕获) + main.c 双核(核1 解码+事务环+边沿环,
核0 CLI + T/W 流), 命令 ?/v/mon/stats/wave/boot; 长事务 56 字节截断('L');
疑似丢沿统计(双位同变启发); wave 快照期间边沿环暂停(有缺口, 已注释)。
- PC 端: i2cdec.py(解码/T行/W行/CSV/编码器) 73 项自测全绿(含 100k/400k
往返、随机鲁棒、截断标志); probelink.py(行协议链路线程); waveview.py
(QPainter 波形); main.py(Fluent 双栏 GUI, 演示模式); theme/config 同源适配;
selftest_gui.py 29 项全绿; build/package/publish 三件套适配; exe 已打包。
- UF2 生成链: objcopy + make_uf2.py(往返自检 + 与真机 MicroPython UF2
逐字段核对通过); 产物保留本地 release/firmware/(交付物不入库, 见 §1 项目约定)。
### 待真机验收清单(下次接板)
- [ ] BPI-Pico 刷 UF2, USB CDC 枚举, `v`/`stats` 回显
- [ ] 挂 demo 板(LT8619C)实听: T 行滚动, 与已知轮询周期(~500ms)对照
- [ ] 0x606E 位交换注入用 SerialMate/旧工具对照, I2C-Lab 监控读数一致
- [ ] wave 快照在 400kHz 下波形完整, 缩放/注记正常
- [ ] 长时间(≥30min)挂机: trans_drop/susp 不增长
- [ ] 验收后: 打 tag `I2C-Lab-v0.1.0` → package → publish(含 UF2)
## 4. 已提出、尚未实现(路线)
- 主机模式: 硬件 I2C 主机 + I2C-Inject 择机注入语义(静置窗口/重试/读回
校验), GUI 寄存器读写卡 + 总线扫描; 命令 wr/rd/scan。
- 从机模拟: 硬件 I2C 从机 + PC 应答表(寄存器值由 GUI 配置/回调)。
- 监控增强: 协议语义插件(LT8619C bank/寄存器注释)、触发条件抓取、
长记录落盘、SMBus(PEC)注记、10bit 地址识别(目前按 7bit 解)。
- 波形增强: 按事务/地址着色、位级数值标注(需边沿级重解码——已具备)。
## 5. 踩坑表(勿重蹈)
| # | 问题 | 教训 |
|---|---|---|
| 1 | 解码器位缓冲在第 8 位清零 | 应凑满 9 位(8 数据+1 应答)才清——首版把 ACK 位错并进下一字节(数据整体右移一位); selftest 往返向量当场抓住 |
| 2 | 事务 acks 数量约定 | acks = 地址应答 + 每数据字节一个(共 n+1); 读事务末字节应答是主机发的 NAK, 造测试向量时别多写 |
| 3 | pico-sdk 2.x 板名 | `rpipico` 已更名 `pico`, 用 `-DPICO_BOARD=pico` |
| 4 | SDK 2.x UF2 依赖 picotool | `pico_add_extra_outputs` 会 FetchContent 拉 github(本机不通); 去掉该行, 用 objcopy+make_uf2.py; UF2 块布局用真机 UF2 解剖核对(偏移 20=blockNo 24=numBlocks 28=familyID), 别按记忆写 |
| 5 | pioasm 需要宿主 C++ 编译器 | 本机无 VS/mingw; 解法: `pip install ziglang`, 在 firmware/build/pioasm 预置 CMakeCache.txt, `CMAKE_CXX_COMPILER:STRING=<zig.exe 路径>;c++;-target;x86_64-windows-gnu;-static`(分号列表, 直接设 CXX 环境变量 CMake 不认) |
| 6 | ghproxy/gitclone 代理稳定性 | ghproxy.net 的 git 协议可用但大仓库会超时(pico-sdk 成功, picotool 两次超时); gitee.com/mirrors/tinyusb 是稳的; 环境变量 CXX 多词值 CMake 报 "Could not find compiler" |
| 7 | CDC 流控假设 | T 行流只在 `mon start` 且 USB 已连接时输出; wave 快照期间边沿环有缺口(暂停写入换一致性), 展示层要容忍 |
| 8 | CMD %ERRORLEVEL% 陷阱 | `cmd1 && cmd2 & echo %ERRORLEVEL%` 的 %ERRORLEVEL% 在解析期展开(恒为旧值); 管道 `findstr` 的退出码也会顶掉 git 的 |
| 9 | PySide6 实例级枚举访问 | `palette().color(pal.Base)` 在 PySide6 6.11 抛 AttributeError(应为 `QPalette.ColorRole.Base` 类级访问); 首帧绘制时才炸, 表象是 `win.show()` 处 access violation(faulthandler 定位), offscreen 自测抓不到——**windows 平台 GUI 冒烟不可省**; 自定义 paintEvent 必须 try/finally `p.end()` |
## 6. 镜像与外部资源
- pico-sdk: `D:\toolchain\pico-sdk`(2.3.1, 浅克隆), tinyusb 锁定 `86ad6e56`
(0.18.0, 来自 gitee.com/mirrors/tinyusb)
- Arm GNU 13.3 / CMake 3.31 / Ninja: `D:\toolchain\*`(PATH 已有 11.3, 13.3 需全路径)
- zig(宿主编译 pioasm 用): `pip install ziglang`, exe 在
`D:\toolchain\zig\ziglang\zig.exe`(pip -t 平铺安装时注意真实路径在包目录下)
- 本仓代理前缀: `https://ghproxy.net/https://github.com/...`(git clone 可用)
+132
View File
@@ -0,0 +1,132 @@
# I2C-Lab — I2C 总线监控调试台
> 更新:2026-10-02。由 **RP2040 probe 固件**(`firmware/`,pico-sdk C,刷入
> BPI-Pico-RP2040)+ **PC 端 GUI**(`src/` / 打包版 `release/I2C-Lab.exe`)组成。
> 在任意 I2C 总线上**纯被动实时监听**:事务流解码 + 逻辑分析仪式波形快照。
> 前身 I2C-Inject(LT8619C 寄存器择机注入)保留在仓库备查;本工具是其
> "完整 I2C 调试台"路线的第一阶段(监控),主机读写与从机模拟见 §7 路线。
---
## 1. 它能做什么
| 能力 | 说明 |
|---|---|
| **实时事务流** | START→STOP 完整解码: 7bit 地址/RW/每字节 ACK/NAK/数据/收尾方式(含 repeated-START 标记) |
| **地址过滤** | 只看关心的地址(十六进制空格分隔), 其余只计数不入表 |
| **波形快照** | 一次抓取最近 256~16384 个 SDA/SCL 边沿, 逻辑分析仪视图: 滚轮缩放(光标锚点)/拖动平移/双击适配/START-STOP 刻线/事务标签 |
| **CSV 导出** | 全量事务(≤2 万条滚动池)导出, Excel 直接打开 |
| **固件计数** | 边沿数/事务数/环形覆盖丢包/疑似丢沿, 判断总线活动与负载 |
| **演示模式** | 无硬件看界面: 模拟 LT8619C demo 板 ~500ms 轮询流量 |
**被动性保证**: probe 两脚为纯输入(PIO 只读电平), 不加上拉、不驱动、
不改总线时序——挂在运行中的 demo 板上零干扰(与 I2C-Inject 同一接线规矩)。
## 2. 硬件准备
| BPI-Pico-RP2040 | 目标总线 |
|---|---|
| GP8(物理脚 11) | SDA |
| GP9(物理脚 12) | SCL |
| GND | GND |
- **不接 VCC、不外加上下拉**(总线由目标板自带上拉)
- 独立台架总线(无主)另说: 需 4.7kΩ 上拉×2 到 3.3V 才有静态高电平
- 固件把系统时钟超到 250MHz(vreg 1.15V), 100/400kHz 总线余量充足;
官方 USB 枚举不受影响
## 3. 刷固件(一次性)
1. 按住 BOOTSEL 上电, 出现 `RPI-RP2` U 盘
2. 把 `release/firmware/i2c_lab.uf2` 拖进去, 板子自动重启
3. 设备管理器出现 `USB 串行设备`(VID 2E8A)即就绪; LED 上电快闪 3 次后,
**常亮 = 监控流开启**
重新构建固件见 `firmware/` 源码与 §6。
## 4. GUI 使用
双击 `I2C-Lab.exe`(或 `python src/main.py`; `--demo` 进演示模式):
1. **固件串口卡**: 选带 `[RP2040]` 标记的端口 → 连接(日志出现版本横幅)
2. **监控卡**: 「开始监控」→ 事务表开始滚动; 「暂停滚动」冻结表格方便读
3. **地址过滤**: 如 `32 50`(十六进制); 空格分隔多个; 留空看全部
4. **波形卡**: 设边沿数(默认 2048) → 「抓取波形」→ SCL/SDA 双线时序图;
滚轮缩放, 拖动平移, 双击适配全宽; 绿/红虚线 = START/STOP
5. **导出CSV**: 保存当前全量事务池(不只表内可见行)
6. 主题: 跟随系统/浅色/深色(DLPilot/SerialMate 同款三件套)
表格列: `# | t0(us) | 时长us | 地址 | RW | ACK | 数据 | 收尾`。
地址 NAK 红色、截断/半字节断橙色、超 16 字节数据显示省略号(悬停看全量)。
## 5. 固件串口命令(可直接用串口终端调)
命令行一行一回, `=` 正常 / `!` 告警(全仓约定):
```
? 帮助
v 版本/时钟/角色
mon start 开 T 行事务流(监控中)
mon stop 停流
stats 计数: edges/trans/trans_drop/susp/ring_ovf/最近边沿距今
wave [n] 倒出最近 n(默认1024, 上限16384)个边沿: W 行 + 'W.' 收尾
boot 重进 BOOTSEL(免按按键)
```
T 行语法(与 PC 端 `i2cdec.py` 互为权威, 自测往返覆盖):
```
T <t0_us> <dur_us> <addr7:02X> <R|W> <flags> <acks> <n> <b1..bn>
flags: '-' stop | 'r' repeated-START 收尾 | 'c' 流截断 | 'p' 半字节断
可追加 'L' = 长事务截断(>56 字节只保留前段)
acks : n+1 个 'A'/'N', 首个是地址应答
W <t_us> <pins> pins bit0=SDA bit1=SCL
```
## 6. 重新构建固件
依赖(本机已装齐, 新机器对照):
- pico-sdk 2.3.1 + tinyusb 子模块(锁定提交), Arm GNU 13.x, CMake ≥3.16, Ninja
- pioasm 由 SDK 构建时自动编译; **宿主若无 C++ 编译器**(如本机), 预置
`firmware/build/pioasm/CMakeCache.txt` 的 `CMAKE_CXX_COMPILER` 为分号列表
形式的 zig(`pip install ziglang` 后指向 ziglang/zig.exe, 见 AGENTS.md 踩坑表)
- SDK 2.x 的 UF2 依赖 picotool(要拉 github); 本仓改用 `make_uf2.py`
(ELF→objcopy→bin→UF2, 与真机 UF2 逐字段核对 + 往返自检)
```powershell
cd firmware
cmake -B build -G Ninja -DPICO_SDK_PATH=D:/toolchain/pico-sdk `
-DPICO_TOOLCHAIN_PATH=D:/toolchain/arm-gnu-toolchain-13.3.rel1 `
-DPICO_BOARD=pico
cmake --build build
<arm-none-eabi-objcopy> -O binary build\i2c_lab.elf build\i2c_lab.bin
python make_uf2.py build\i2c_lab.bin build\i2c_lab.uf2
copy build\i2c_lab.uf2 ..\release\firmware\
```
## 7. 路线(与 I2C-Inject 的关系)
- [x] **v0.1 监控**(本版): 纯被动监听 + 事务流 + 波形快照
- [ ] **主机模式**: probe 作为 I2C 主机发起读写/总线扫描(复活 I2C-Inject 的
择机注入语义——避让主控轮询、静置窗口注入、读回校验), GUI 加寄存器读写卡
- [ ] **从机模拟**: probe 应答主机, 应答内容由 PC 端配置(RP2040 硬件 I2C
从机 + 应答表), 用于顶替传感器/MCU 验证驱动
- [ ] 监控增强: 字段级协议插件(如 LT8619C bank/寄存器语义注释)、触发抓取、
长时记录落盘
## 8. 故障排查
- 连接后无 T 行: `stats` 看 edges 是否增长; 不增长查接线/共地/上拉
- 事务乱码: 确认 SDA/SCL 没接反(GP8=SDA!); `wave 512` 看原始边沿
- `trans_drop`/`susp` 持续增长: 总线负载过高或毛刺多, 减小监控过滤范围
- 波形抓取期间的事务流有缺口: 正常(`wave` 快照优先, 见固件注释)
## 9. 目录内容
| 位置 | 用途 |
|---|---|
| `src/` | PC 端: main.py(GUI)/i2cdec.py(解码权威)/probelink.py(链路)/waveview.py(波形)/theme.py + 自检/打包/发布脚本 |
| `firmware/` | probe 固件: main.c + bus_watch.pio + CMakeLists + make_uf2.py + pico_sdk_import.cmake |
| `assets/` | 图标(沿用 I2C 橙色母题) |
| `release/` | I2C-Lab.exe + firmware/i2c_lab.uf2/.bin(交付物本地保留,不入库); zip 不入库 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#000000"/>
<rect x="38" y="64" width="436" height="186" rx="34" fill="#FF9900"/>
<text x="256" y="210" font-family="Arial, 'Microsoft YaHei', sans-serif" font-size="146" font-weight="900" fill="#000000" text-anchor="middle">I2C</text>
<text x="256" y="421" font-family="Arial, 'Microsoft YaHei', sans-serif" font-size="146" font-weight="900" fill="#FFFFFF" text-anchor="middle">Lab</text>
</svg>

After

Width:  |  Height:  |  Size: 504 B

+27
View File
@@ -0,0 +1,27 @@
# I2C-Lab probe 固件(RP2040 / pico-sdk)
# 构建(见 ../README.md §固件):
# cmake -B build -G Ninja -DPICO_SDK_PATH=<sdk> -DPICO_TOOLCHAIN_PATH=<arm-gcc>
# cmake --build build
# 注: 不用 pico_add_extra_outputs(它会 FetchContent 拉 github 上的 picotool,
# 本机网络不通)。UF2 由 make_uf2.py 从 .bin 生成(带往返自检)。
cmake_minimum_required(VERSION 3.16)
include(pico_sdk_import.cmake) # 由 pico-sdk/external/ 复制
project(i2c_lab_fw C CXX ASM)
set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 17)
pico_sdk_init()
add_executable(i2c_lab main.c)
pico_generate_pio_header(i2c_lab ${CMAKE_CURRENT_LIST_DIR}/bus_watch.pio)
target_link_libraries(i2c_lab
pico_stdlib
pico_multicore
hardware_pio
hardware_clocks
hardware_vreg
)
pico_enable_stdio_usb(i2c_lab 1)
pico_enable_stdio_uart(i2c_lab 0)
+31
View File
@@ -0,0 +1,31 @@
;
; bus_watch.pio — I2C 总线被动边沿捕获(RP2040 PIO)
; ==================================================
; 思路同 pico-examples 的 logic_analyser: 硬件只做最难的"确定性捕获"
; (电平变化即压入 FIFO), 协议解码(I2C 状态机)留给 CPU——与 PC 端
; i2cdec.py 共享同一套算法语义(源自 I2C-Inject 真机验证的
; i2c_sniff_decode.py)。
;
; 接线约定(沿用 I2C-Inject): GP8=SDA, GP9=SCL, 共地, 不加上拉,
; 纯输入不驱动(开漏总线零干扰)。
; 引脚映射: in_base = GP8 → ISR/X/Y 的 bit0=SDA bit1=SCL。
;
; 每个 FIFO 字 = 新电平值(0..3)。时间戳由 CPU 取(边沿间隔 ≥ 500ns,
; 核1 专职轮询, 抖动远小于 1us)。
; 环形轮询 5 条指令 @250MHz = 20ns/圈, 可分辨 ~40ns 毛刺;
; push noblock: FIFO 满则丢当前沿(Y 已更新, 后续沿仍连续可解,
; CPU 侧以"双位同变"启发式统计疑似丢沿)。
.program bus_watch
top:
mov isr, null ; ISR 清零(低 2 位待填)
in pins, 2 ; ISR[1:0] = SCL<<1 | SDA
mov x, isr
jmp x != y, changed ; 电平有变 -> 送出
jmp top ; 无变 -> 继续盯
changed:
mov y, x ; 先更新基线(即使 push 被丢也不失步)
push noblock ; 送出新电平(FIFO 满则丢弃)
jmp top
+388
View File
@@ -0,0 +1,388 @@
/**
* I2C-Lab probe — RP2040 I2C 总线被动监控固件 v0.1.0
* ==================================================================
* 硬件: BPI-Pico-RP2040(与 Raspberry Pi Pico 引脚兼容)
* 接线: GP8=SDA, GP9=SCL, GND 共地; 纯被动(不加上拉/不驱动)。
*
* 架构(双核分工):
* core1 硬实时: 轮询 PIO bus_watch SM 的 RX FIFO, 取 µs 时间戳,
* 跑 I2C 解码状态机(语义与 PC 端 i2cdec.py 一致) →
* 事务环形缓冲(核0 消费) + 边沿环形缓冲(波形快照用);
* core0 USB CDC 文本命令行(全仓 `=`/`!` 回显约定), T 行事务流,
* W 行边沿快照, LED 指示。
*
* 监控行协议(与 i2cdec.py 的 to_tline/parse_tline 对齐):
* T <t0_us> <dur_us> <addr7:02X> <R|W> <flags> <acks> <n> <b1..bn>
* flags: '-' stop | 'r' repeated-START 收尾 | 'c' 流截断
* | 'p' 半字节断, 可追加 'L' = 长事务截断(>56 字节)
* acks : n+1 个 'A'/'N'(首个为地址应答)
* W <t_us> <pins> 边沿快照行(pins bit0=SDA bit1=SCL), 'W.' 结束
* '=' 开头为命令应答, '!' 开头为告警
*
* 命令: ? | v | mon start|stop | stats | wave [n] | mon led ...
* 时钟: 250MHz(overclock, vreg 1.15V), USB 独立 PLL 不受影响。
*/
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "pico/stdlib.h"
#include "pico/multicore.h"
#include "pico/bootrom.h"
#include "pico/stdio_usb.h"
#include "hardware/pio.h"
#include "hardware/clocks.h"
#include "hardware/vreg.h"
#include "bus_watch.pio.h"
#define FW_VERSION "0.1.0"
#define PIN_SDA 8
#define PIN_SCL 9
#define PIN_LED 25
#define PIO_BUS pio0
/* 长事务只保留前 DATA_CAP 个数据字节(n 总数照计, flags 加 'L') */
#define DATA_CAP 56
#define TRANS_RING_SIZE 256u /* 事务环形缓冲条数(2 的幂) */
#define EDGE_RING_SIZE (16u * 1024u) /* 边沿环形缓冲条数(2 的幂), 64KB */
#define EDGE_DT_MAX 0xFFFFFEu /* Δt 压缩上限(>16.7s 的间隙按此记) */
enum { END_STOP = 0, END_RSTART, END_CUT, END_PARTIAL };
typedef struct {
uint32_t t0;
uint32_t dur;
uint8_t addr7;
uint8_t rw; /* 0=W 1=R */
uint8_t ended; /* END_* */
uint8_t truncated; /* 长事务截断 */
uint8_t n; /* 记录的数据字节数 ≤ DATA_CAP */
uint8_t ackn; /* 记录的应答数 = n+1 */
uint8_t data[DATA_CAP];
uint64_t acks; /* bit i(i<ackn): 1=ACK(SDA=0) */
} trans_rec_t;
/* ---------------- 共享状态(核1 生产 / 核0 消费) ---------------- */
typedef struct {
uint32_t edges; /* 捕获到的边沿总数 */
uint32_t trans; /* 解码完成的事务总数 */
uint32_t trans_drop; /* 事务环形覆盖丢数(消费不及) */
uint32_t susp; /* 疑似丢沿(双位同变) */
uint32_t ring_ovf; /* 边沿环覆盖次数 */
uint32_t last_edge_t; /* 最后边沿的绝对时间戳(us) */
} bus_stats_t;
volatile bus_stats_t stats = {0};
volatile trans_rec_t trans_ring[TRANS_RING_SIZE];
volatile uint32_t trans_head = 0; /* 核1 写 */
volatile uint32_t trans_tail_pub = 0;/* 核1 维护(覆盖时前推); 核0 参考 */
volatile uint32_t edge_ring[EDGE_RING_SIZE];
volatile uint32_t edge_head = 0; /* 核1 写 */
volatile uint32_t edge_newest_t = 0; /* 最新边沿绝对时间(us) */
volatile uint32_t edge_hold = 0; /* 核0 置位: 暂停边沿入环(wave 快照) */
/* ---------------- core1: 捕获 + 解码 ---------------- */
static uint bus_sm; /* SM 号(core0 声明, 双核共用) */
static struct {
int in_txn;
uint32_t t0;
uint8_t bytes[1 + DATA_CAP]; /* [0]=地址字节 */
uint8_t nbits, shift;
int nbytes_stored; /* 已存字节(含地址) */
int ntotal; /* 总字节(含地址, 含未存) */
uint64_t acks;
int ackn;
} d;
static uint32_t prev_sda, prev_scl, have_prev;
static uint32_t ring_last_t;
static void push_trans(uint32_t t1, int reason) {
if (!d.in_txn) return;
int ended = reason;
if (reason == END_CUT && d.nbits != 0) ended = END_PARTIAL;
trans_rec_t r;
memset(&r, 0, sizeof r);
r.t0 = d.t0;
r.dur = t1 - d.t0;
uint8_t addr_byte = (d.nbytes_stored > 0) ? d.bytes[0] : 0;
r.addr7 = addr_byte >> 1;
r.rw = addr_byte & 1;
r.ended = (uint8_t)ended;
int ndata_total = (d.ntotal > 0) ? d.ntotal - 1 : 0;
r.truncated = ndata_total > DATA_CAP;
int n_listed = d.nbytes_stored - 1;
if (n_listed > DATA_CAP) n_listed = DATA_CAP;
if (n_listed < 0) n_listed = 0;
r.n = (uint8_t)n_listed;
int ackn_listed = d.ackn < (n_listed + 1) ? d.ackn : n_listed + 1;
r.ackn = (uint8_t)ackn_listed;
memcpy(r.data, d.bytes + 1, (size_t)n_listed);
r.acks = d.acks; /* 高位未置位的应答位视为 N/A, 打印按 ackn */
uint32_t h = trans_head;
if (h - trans_tail_pub >= TRANS_RING_SIZE) { /* 覆盖最旧 */
stats.trans_drop++;
trans_tail_pub = h - TRANS_RING_SIZE + 1;
}
trans_ring[h & (TRANS_RING_SIZE - 1)] = r;
trans_head = h + 1;
stats.trans++;
d.in_txn = 0;
}
static void open_txn(uint32_t t) {
d.in_txn = 1;
d.t0 = t;
d.nbits = d.shift = 0;
d.nbytes_stored = 0;
d.ntotal = 0;
d.acks = 0;
d.ackn = 0;
}
static void store_byte(uint8_t v) {
d.ntotal++;
if (d.nbytes_stored < (int)(sizeof d.bytes)) d.bytes[d.nbytes_stored++] = v;
}
/* 一个 FIFO 字(新电平)的处理: 解码状态机 + 边沿入环 */
static void feed_word(uint32_t pins, uint32_t t) {
uint32_t sda = pins & 1, scl = (pins >> 1) & 1;
if (have_prev) {
if (scl && prev_scl) { /* SCL 高电平期 SDA 变化 */
if (prev_sda && !sda) { /* START / repeated START */
push_trans(t, END_RSTART);
open_txn(t);
} else if (!prev_sda && sda) { /* STOP */
push_trans(t, END_STOP);
}
} else if (scl && !prev_scl) { /* SCL 上升沿采样 */
if (d.in_txn) {
if (d.nbits < 8) {
d.shift = (uint8_t)((d.shift << 1) | sda);
if (++d.nbits == 8) store_byte(d.shift);
} else { /* 第 9 位 = 应答 */
if (d.ackn < 64 && !sda)
d.acks |= 1ull << d.ackn;
d.ackn++;
d.nbits = 0;
d.shift = 0;
}
}
}
if ((sda != prev_sda) && (scl != prev_scl))
stats.susp++; /* 合法 I2C 不应双位同变 */
}
prev_sda = sda;
prev_scl = scl;
have_prev = 1;
stats.edges++;
stats.last_edge_t = t;
if (!edge_hold) { /* 边沿环(波形快照源) */
uint32_t dt = t - ring_last_t;
if (dt > EDGE_DT_MAX) dt = EDGE_DT_MAX;
uint32_t h = edge_head;
if (h && (h & (EDGE_RING_SIZE - 1)) == 0)
stats.ring_ovf++; /* 每绕环覆盖一圈记一次 */
edge_ring[h & (EDGE_RING_SIZE - 1)] = (dt << 2) | pins;
edge_head = h + 1;
edge_newest_t = t;
}
ring_last_t = t;
}
static void core1_entry(void) {
uint sm = bus_sm; /* 由 core0 声明并初始化 */
while (true) {
while (!pio_sm_is_rx_fifo_empty(PIO_BUS, sm)) {
uint32_t w = pio_sm_get(PIO_BUS, sm) & 3;
feed_word(w, time_us_32());
}
tight_loop_contents();
}
}
/* ---------------- core0: 命令行 + 事务流 ---------------- */
static volatile bool mon_active = false;
static void cmd_help(void) {
printf("= 命令: ? 帮助 | v 版本 | mon start|stop 监控流开关"
" | stats 计数 | wave [n] 最近 n 个边沿快照(默认1024, 上限%u)"
" | boot 重进 BOOTSEL\n", EDGE_RING_SIZE);
}
static void cmd_stats(void) {
uint32_t now = time_us_32();
uint32_t ago = now - stats.last_edge_t;
printf("= stats edges=%u trans=%u trans_drop=%u susp=%u ring_ovf=%u "
"last_edge_ago_us=%u mon=%d\n",
stats.edges, stats.trans, stats.trans_drop, stats.susp,
stats.ring_ovf, ago, mon_active ? 1 : 0);
}
static void cmd_wave(uint32_t n) {
if (n == 0) n = 1024;
if (n > EDGE_RING_SIZE) n = EDGE_RING_SIZE;
edge_hold = 1;
sleep_ms(2); /* 等核1 让出环写 */
uint32_t h = edge_head;
uint32_t stored = h < EDGE_RING_SIZE ? h : EDGE_RING_SIZE;
uint32_t m = n < stored ? n : stored;
if (m == 0) {
printf("! wave 无边沿可倒出\n");
edge_hold = 0;
return;
}
/* 边沿环存 Δt: 先求区间内 Δ 和, 反推最旧一条的绝对时间 */
uint32_t sum_dt = 0;
for (uint32_t i = h - m + 1; i < h; i++)
sum_dt += edge_ring[i & (EDGE_RING_SIZE - 1)] >> 2;
uint32_t t = edge_newest_t - sum_dt;
printf("= wave %u\n", m);
for (uint32_t i = h - m; i < h; i++) {
uint32_t e = edge_ring[i & (EDGE_RING_SIZE - 1)];
printf("W %u %u\n", t, e & 3);
t += e >> 2;
}
printf("W.\n");
edge_hold = 0; /* 快照期间新边沿有缺口, stats.susp 可见 */
}
static void emit_trans(volatile trans_rec_t *r) {
char flags[4] = "-rcp";
char f[3] = {flags[r->ended], 0, 0};
if (r->truncated) { f[1] = 'L'; f[2] = 0; }
char acks[DATA_CAP + 2];
for (int i = 0; i < r->ackn; i++)
acks[i] = (r->acks & (1ull << i)) ? 'A' : 'N';
acks[r->ackn] = 0;
printf("T %u %u %02X %c %s %s %u", r->t0, r->dur, r->addr7,
r->rw ? 'R' : 'W', f, acks, r->n);
for (int i = 0; i < r->n; i++)
printf(" %02X", r->data[i]);
printf("\n");
}
/* 消费事务环: 监控开则打印, 关则丢弃计数 */
static void drain_trans(void) {
static uint32_t tail = 0; /* 核0 本地 */
uint32_t budget = 128; /* 每轮上限, 保证命令行响应 */
while (budget--) {
if (tail < trans_tail_pub) tail = trans_tail_pub;
if (tail >= trans_head) break;
volatile trans_rec_t *r = &trans_ring[tail & (TRANS_RING_SIZE - 1)];
if (mon_active && stdio_usb_connected())
emit_trans(r);
tail++;
}
}
static void handle_line(char *line) {
/* 就地去 CR/LF 与空白 */
size_t len = strlen(line);
while (len && (line[len - 1] == '\r' || line[len - 1] == '\n'
|| isspace((unsigned char)line[len - 1])))
line[--len] = 0;
char *save = NULL;
char *cmd = strtok_r(line, " \t", &save);
if (!cmd) return;
if (!strcmp(cmd, "?") || !strcmp(cmd, "help")) {
cmd_help();
} else if (!strcmp(cmd, "v")) {
printf("= i2c-lab probe v%s GP8=SDA GP9=SCL sys=%uMHz "
"(被动监控, T/W 行协议)\n", FW_VERSION,
(unsigned)(clock_get_hz(clk_sys) / 1000000));
} else if (!strcmp(cmd, "mon")) {
char *sub = strtok_r(NULL, " \t", &save);
if (sub && !strcmp(sub, "start")) {
mon_active = true;
printf("= mon start (T 行流开启)\n");
} else if (sub && !strcmp(sub, "stop")) {
mon_active = false;
printf("= mon stop\n");
} else {
printf("! mon start|stop\n");
}
} else if (!strcmp(cmd, "stats")) {
cmd_stats();
} else if (!strcmp(cmd, "wave")) {
char *arg = strtok_r(NULL, " \t", &save);
cmd_wave(arg ? (uint32_t)strtoul(arg, NULL, 0) : 1024);
} else if (!strcmp(cmd, "boot")) {
printf("= 重进 BOOTSEL\n");
sleep_ms(50);
reset_usb_boot(0, 0);
} else {
printf("! 未知命令 '%s', '?' 看帮助\n", cmd);
}
}
int main(void) {
vreg_set_voltage(VREG_VOLTAGE_1_15);
set_sys_clock_khz(250000, false); /* 超频提高捕获余量; 失败回落默认 */
stdio_init_all();
/* PIO: 总线被动监视(纯输入, 关上下拉) */
uint off = pio_add_program(PIO_BUS, &bus_watch_program);
bus_sm = pio_claim_unused_sm(PIO_BUS, true);
pio_sm_config c = bus_watch_program_get_default_config(off);
sm_config_set_in_pins(&c, PIN_SDA); /* in_base=8 → bit0=SDA bit1=SCL */
sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_RX);
pio_sm_init(PIO_BUS, bus_sm, off, &c);
pio_gpio_init(PIO_BUS, PIN_SDA);
pio_gpio_init(PIO_BUS, PIN_SCL);
gpio_disable_pulls(PIN_SDA);
gpio_disable_pulls(PIN_SCL);
/* 纯输入: 不设 pindir, 程序也从不驱动 — 对开漏总线零干扰 */
gpio_init(PIN_LED);
gpio_set_dir(PIN_LED, GPIO_OUT);
multicore_launch_core1(core1_entry);
for (int i = 0; i < 3; i++) { /* 上电自检快闪 3 次(同 I2C-Inject) */
gpio_put(PIN_LED, 1); sleep_ms(80);
gpio_put(PIN_LED, 0); sleep_ms(80);
}
printf("= i2c-lab probe v%s ready GP8=SDA GP9=SCL ('?' 帮助)\n",
FW_VERSION);
char buf[128];
size_t bi = 0;
bool bannered = false;
while (true) {
/* 命令行: 非阻塞收字符组行 */
int ch = getchar_timeout_us(0);
if (ch != PICO_ERROR_TIMEOUT) {
if (ch == '\r' || ch == '\n') {
if (bi < sizeof buf) buf[bi] = 0;
if (bi) handle_line(buf);
bi = 0;
} else if (bi < sizeof buf - 1) {
buf[bi++] = (char)ch;
}
}
/* USB 枚举完成后补发横幅(插入即见) */
if (!bannered && stdio_usb_connected()) {
bannered = true;
printf("= i2c-lab probe v%s connected GP8=SDA GP9=SCL\n",
FW_VERSION);
}
drain_trans();
gpio_put(PIN_LED, mon_active); /* 常亮 = 监控中(同注入器约定) */
sleep_ms(1);
}
}
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
make_uf2.py — 最小 UF2 封装器(RP2040)
==================================================
背景: pico-sdk 2.x 生成 UF2 依赖 picotool(FetchContent 拉 github), 本机
网络对 github 不通时走本脚本替代: elf 先经 arm-none-eabi-objcopy 转 bin,
再按 UF2 规范(Microsoft UF2 spec, family 变体)打包。
用法:
python make_uf2.py <app.bin> <out.uf2> [base_addr_hex]
python make_uf2.py --test # 内部往返自检(解包回来比对 payload)
UF2 块格式(512B/块): magic0 0x0A324655, magic1 0x9E5D5157,
flags 0x00002000(family), addr, len(≤476, 本工具固定 256), size, blockNo,
numBlocks, familyId=0xE48BFF56(RP2040), data[476], magicEnd 0x0AB16F30。
"""
import struct
import sys
MAGIC0 = 0x0A324655
MAGIC1 = 0x9E5D5157
MAGIC_END = 0x0AB16F30
FLAG_FAMILY = 0x00002000
FAMILY_RP2040 = 0xE48BFF56
RP2040_FLASH_BASE = 0x10000000
PAYLOAD = 256 # RP2040 flash 页大小, 256 对齐最稳
def bin_to_uf2(data: bytes, base: int = RP2040_FLASH_BASE,
family: int = FAMILY_RP2040) -> bytes:
"""块布局按官方 uf2conv 实现(经真机 UF2 逐块核对):
0:magic0 4:magic1 8:flags 12:targetAddr 16:payloadSize
20:blockNo 24:numBlocks 28:familyID 32:data[476] 508:magicEnd"""
if len(data) % PAYLOAD: # 补齐到整页
data = data + b'\x00' * (PAYLOAD - len(data) % PAYLOAD)
total = len(data) // PAYLOAD
out = bytearray()
for i in range(total):
chunk = data[i * PAYLOAD:(i + 1) * PAYLOAD]
block = struct.pack(
'<IIIIIIII',
MAGIC0, MAGIC1, FLAG_FAMILY,
base + i * PAYLOAD, # targetAddr
PAYLOAD, # payloadSize
i, total, # blockNo / numBlocks
family, # fileSize 槽位 = familyID
)
block += chunk
block += b'\x00' * (476 - PAYLOAD) # data 区补齐到 476
block += struct.pack('<I', MAGIC_END)
assert len(block) == 512
out += block
return bytes(out)
def uf2_to_payload(uf2: bytes):
"""解包自检用: 还原 (base, payload bytes); 非法块抛 ValueError"""
if len(uf2) % 512:
raise ValueError(f'uf2 长度 {len(uf2)} 不是 512 的倍数')
blocks = [uf2[i:i + 512] for i in range(0, len(uf2), 512)]
base = None
total = None
chunks = {}
for n, b in enumerate(blocks):
(m0, m1, flags, addr, nb, no, nt, fam) = struct.unpack_from('<8I', b)
if m0 != MAGIC0 or m1 != MAGIC1:
raise ValueError(f'块 {n} magic 错')
if struct.unpack_from('<I', b, 508)[0] != MAGIC_END:
raise ValueError(f'块 {n} 尾 magic 错')
if flags & FLAG_FAMILY and fam != FAMILY_RP2040:
raise ValueError(f'块 {n} family 非 RP2040: 0x{fam:08X}')
if nb > 476:
raise ValueError(f'块 {n} payloadSize 超限')
if base is None:
base, total = addr, nt
if no != n or nt != total:
raise ValueError(f'块 {n} 序号/总数错')
chunks[no] = (addr, b[32:32 + nb])
payload = bytearray()
expect = base
for i in range(total):
if i not in chunks:
raise ValueError(f'缺块 {i}')
addr, c = chunks[i]
if addr != expect:
raise ValueError(f'块 {i} 地址不连续: {addr:#x} != {expect:#x}')
payload += c
expect += len(c)
return base, bytes(payload)
def _selftest():
import random
rng = random.Random(7)
for size in (0, 1, 255, 256, 257, 4096, 4097, 65536):
blob = bytes(rng.randrange(256) for _ in range(size))
if size == 0:
continue # 空镜像无意义(0 块)
uf2 = bin_to_uf2(blob, base=0x10000000)
base, back = uf2_to_payload(uf2)
pad = (-size) % PAYLOAD
assert base == 0x10000000
assert back[:size] == blob and len(back) == size + pad, \
f'size={size} 往返不一致'
print(f'PASS make_uf2 往返自检 ({(size + (-size) % PAYLOAD) // PAYLOAD} 块)')
return 0
def main(argv):
if len(argv) >= 2 and argv[1] == '--test':
return _selftest()
if len(argv) < 3:
print(__doc__)
return 2
with open(argv[1], 'rb') as f:
data = f.read()
base = int(argv[3], 16) if len(argv) > 3 else RP2040_FLASH_BASE
uf2 = bin_to_uf2(data, base=base)
with open(argv[2], 'wb') as f:
f.write(uf2)
print(f'UF2: {len(uf2)} B, {len(uf2) // 512} 块, '
f'base=0x{base:08X}, payload={len(data)} B -> {argv[2]}')
# 写完即自检
with open(argv[2], 'rb') as f:
b, p = uf2_to_payload(f.read())
assert b == base and p[:len(data)] == data
print('PASS 写出文件解包往返一致')
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv))
+121
View File
@@ -0,0 +1,121 @@
# This is a copy of <PICO_SDK_PATH>/external/pico_sdk_import.cmake
# This can be dropped into an external project to help locate this SDK
# It should be include()ed prior to project()
# Copyright 2020 (c) 2020 Raspberry Pi (Trading) Ltd.
#
# Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
# following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following
# disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
if (DEFINED ENV{PICO_SDK_PATH} AND (NOT PICO_SDK_PATH))
set(PICO_SDK_PATH $ENV{PICO_SDK_PATH})
message("Using PICO_SDK_PATH from environment ('${PICO_SDK_PATH}')")
endif ()
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT} AND (NOT PICO_SDK_FETCH_FROM_GIT))
set(PICO_SDK_FETCH_FROM_GIT $ENV{PICO_SDK_FETCH_FROM_GIT})
message("Using PICO_SDK_FETCH_FROM_GIT from environment ('${PICO_SDK_FETCH_FROM_GIT}')")
endif ()
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT_PATH} AND (NOT PICO_SDK_FETCH_FROM_GIT_PATH))
set(PICO_SDK_FETCH_FROM_GIT_PATH $ENV{PICO_SDK_FETCH_FROM_GIT_PATH})
message("Using PICO_SDK_FETCH_FROM_GIT_PATH from environment ('${PICO_SDK_FETCH_FROM_GIT_PATH}')")
endif ()
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT_TAG} AND (NOT PICO_SDK_FETCH_FROM_GIT_TAG))
set(PICO_SDK_FETCH_FROM_GIT_TAG $ENV{PICO_SDK_FETCH_FROM_GIT_TAG})
message("Using PICO_SDK_FETCH_FROM_GIT_TAG from environment ('${PICO_SDK_FETCH_FROM_GIT_TAG}')")
endif ()
if (PICO_SDK_FETCH_FROM_GIT AND NOT PICO_SDK_FETCH_FROM_GIT_TAG)
set(PICO_SDK_FETCH_FROM_GIT_TAG "master")
message("Using master as default value for PICO_SDK_FETCH_FROM_GIT_TAG")
endif()
set(PICO_SDK_PATH "${PICO_SDK_PATH}" CACHE PATH "Path to the Raspberry Pi Pico SDK")
set(PICO_SDK_FETCH_FROM_GIT "${PICO_SDK_FETCH_FROM_GIT}" CACHE BOOL "Set to ON to fetch copy of SDK from git if not otherwise locatable")
set(PICO_SDK_FETCH_FROM_GIT_PATH "${PICO_SDK_FETCH_FROM_GIT_PATH}" CACHE FILEPATH "location to download SDK")
set(PICO_SDK_FETCH_FROM_GIT_TAG "${PICO_SDK_FETCH_FROM_GIT_TAG}" CACHE FILEPATH "release tag for SDK")
if (NOT PICO_SDK_PATH)
if (PICO_SDK_FETCH_FROM_GIT)
include(FetchContent)
set(FETCHCONTENT_BASE_DIR_SAVE ${FETCHCONTENT_BASE_DIR})
if (PICO_SDK_FETCH_FROM_GIT_PATH)
get_filename_component(FETCHCONTENT_BASE_DIR "${PICO_SDK_FETCH_FROM_GIT_PATH}" REALPATH BASE_DIR "${CMAKE_SOURCE_DIR}")
endif ()
FetchContent_Declare(
pico_sdk
GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk
GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG}
)
if (NOT pico_sdk)
message("Downloading Raspberry Pi Pico SDK")
# GIT_SUBMODULES_RECURSE was added in 3.17
if (${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.17.0")
FetchContent_Populate(
pico_sdk
QUIET
GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk
GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG}
GIT_SUBMODULES_RECURSE FALSE
SOURCE_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-src
BINARY_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-build
SUBBUILD_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-subbuild
)
else ()
FetchContent_Populate(
pico_sdk
QUIET
GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk
GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG}
SOURCE_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-src
BINARY_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-build
SUBBUILD_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-subbuild
)
endif ()
set(PICO_SDK_PATH ${pico_sdk_SOURCE_DIR})
endif ()
set(FETCHCONTENT_BASE_DIR ${FETCHCONTENT_BASE_DIR_SAVE})
else ()
message(FATAL_ERROR
"SDK location was not specified. Please set PICO_SDK_PATH or set PICO_SDK_FETCH_FROM_GIT to on to fetch from git."
)
endif ()
endif ()
get_filename_component(PICO_SDK_PATH "${PICO_SDK_PATH}" REALPATH BASE_DIR "${CMAKE_BINARY_DIR}")
if (NOT EXISTS ${PICO_SDK_PATH})
message(FATAL_ERROR "Directory '${PICO_SDK_PATH}' not found")
endif ()
set(PICO_SDK_INIT_CMAKE_FILE ${PICO_SDK_PATH}/pico_sdk_init.cmake)
if (NOT EXISTS ${PICO_SDK_INIT_CMAKE_FILE})
message(FATAL_ERROR "Directory '${PICO_SDK_PATH}' does not appear to contain the Raspberry Pi Pico SDK")
endif ()
set(PICO_SDK_PATH ${PICO_SDK_PATH} CACHE PATH "Path to the Raspberry Pi Pico SDK" FORCE)
include(${PICO_SDK_INIT_CMAKE_FILE})
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
把 src/main.py 打包成单文件 exe(工具名: I2C-Lab)
================================================
与 SerialMate/src/build_exe.py 同一套路: onefile + --noconsole, Python 解释器、
PySide6 和 PySide6-Fluent-Widgets 全部打进一个 exe, 目标机器无需安装依赖。
用法:
python build_exe.py 自动安装 PyInstaller(如缺)并打包
python build_exe.py --debug 额外打一个带控制台的变体
产物: release/I2C-Lab.exe
spec 由本脚本生成到 src/build/(不入库), 调整打包逻辑直接改这里的模板。
"""
import os
import subprocess
import sys
# 本脚本位于 src/, 打包产物输出到工具根的 release/
SRC = os.path.dirname(os.path.abspath(__file__))
BASE = os.path.dirname(SRC)
RELEASE = os.path.join(BASE, 'release')
WORK = os.path.join(SRC, 'build')
ICO = os.path.join(BASE, 'assets', 'i2c-lab.ico')
SPEC_TEMPLATE = """\
# -*- coding: utf-8 -*-
# 由 build_exe.py 生成, 不要手改; 调整打包逻辑请改 src/build_exe.py。
from PyInstaller.utils.hooks import collect_all
qfluent_datas, qfluent_binaries, qfluent_hidden = collect_all('qfluentwidgets')
a = Analysis(
[r'{entry}'], pathex=[r'{src}'], binaries=qfluent_binaries,
datas=qfluent_datas + {icon_datas}, hiddenimports=qfluent_hidden,
noarchive=False)
# Qt 6.11 使用 Windows 非版本化 ICU 导出; PATH 上 Poppler/Conda 的版本化
# ICU(icuuc.dll/icudt78.dll)会被 PyInstaller 误收, 令 QtCore.pyd 启动报
# WinError 127 —— 打包时剔除, 运行时用系统自带 ICU。
a.binaries = [b for b in a.binaries if b[0].lower() not in ('icuuc.dll', 'icudt78.dll')]
pyz = PYZ(a.pure)
exe = EXE(pyz, a.scripts, a.binaries, a.datas, [], name='{name}',
debug=False, strip=False, upx=False, console={console}{icon_clause})
"""
def ensure_pyinstaller():
try:
import PyInstaller # noqa: F401
return
except ImportError:
pass
print('PyInstaller 未安装, 正在安装...')
subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'pyinstaller'])
def remove_stale(exe_path):
"""删除旧 exe; 若被占用(上次验证的进程还在), 先结束同名进程再重试"""
if not os.path.exists(exe_path):
return
try:
os.remove(exe_path)
return
except PermissionError:
pass
import time
print(f'{exe_path} 被占用, 结束残留进程后重试...')
subprocess.run(['taskkill', '/F', '/IM', os.path.basename(exe_path)],
capture_output=True)
time.sleep(1.0)
os.remove(exe_path)
def build(name, console):
remove_stale(os.path.join(RELEASE, name + '.exe'))
if os.path.exists(ICO):
icon_datas = f"[(r'{ICO}', 'assets')]"
icon_clause = f", icon=r'{ICO}'"
else:
print('警告: 未找到 assets/i2c-lab.ico, 本次打包不带图标')
icon_datas, icon_clause = '[]', ''
spec = SPEC_TEMPLATE.format(entry=os.path.join(SRC, 'main.py'), src=SRC,
icon_datas=icon_datas, name=name,
console=console, icon_clause=icon_clause)
os.makedirs(WORK, exist_ok=True)
spec_path = os.path.join(WORK, name + '.spec')
with open(spec_path, 'w', encoding='utf-8') as f:
f.write(spec)
cmd = [sys.executable, '-m', 'PyInstaller', '--noconfirm', '--clean',
'--distpath', RELEASE, '--workpath', WORK, spec_path]
print('\n>>', ' '.join(cmd))
subprocess.check_call(cmd)
out = os.path.join(RELEASE, name + '.exe')
print(f'完成: {out} ({os.path.getsize(out) / 1024 / 1024:.1f} MB)')
def main():
build_console = '--debug' in sys.argv
ensure_pyinstaller()
# 常规版: 双击即用, 无黑框
build('I2C-Lab', 'False')
if build_console:
# 调试版: 带控制台, 异常栈可见
build('I2C-Lab-console', 'True')
if __name__ == '__main__':
main()
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
运行目录定位与设置持久化(BASE_DIR 语义与 SerialMate/DLPilot config.py 一致)
==========================================================================
frozen(PyInstaller onefile)时 BASE_DIR=exe 所在目录, 源码运行时=本文件目录。
设置写/读自 BASE_DIR/i2c_lab.json, 不建子目录。
读容错: 缺失/损坏/缺键一律回落默认值, GUI 无需判空。
"""
import json
import os
import sys
if getattr(sys, 'frozen', False):
BASE_DIR = os.path.dirname(os.path.abspath(sys.executable))
else:
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_FILE = os.path.join(BASE_DIR, 'i2c_lab.json')
DEFAULTS = {
'serial': {'port': ''},
'mon': {'addr_filter': '', 'paused': False, 'wave_n': 2048},
}
def _merge(base, over):
"""递归覆盖: over 缺键用 base 补齐, 多余键保留(向前兼容)"""
out = dict(base)
for k, v in over.items():
if k in base and isinstance(base[k], dict) and isinstance(v, dict):
out[k] = _merge(base[k], v)
else:
out[k] = v
return out
def load():
"""读设置; 任何异常回落默认(键全齐), 不抛出"""
try:
with open(CONFIG_FILE, encoding='utf-8') as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError('配置根不是对象')
except Exception:
return json.loads(json.dumps(DEFAULTS)) # 深拷贝默认值
return _merge(DEFAULTS, data)
def save(cfg):
"""写设置; 失败静默(只读目录等场景不阻塞退出)"""
try:
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(cfg, f, ensure_ascii=False, indent=1)
except Exception:
pass
+333
View File
@@ -0,0 +1,333 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
i2cdec.py — I2C 领域模型/解码(纯标准库)
==================================================
本模块是 I2C-Lab 三方共用的"协议语法"权威:
- 固件监控流 T 行(事务)与 W 行(边沿快照)的解析(与 firmware/main.c 语法一致);
- 边沿序列 -> 事务解码 decode_edges()(算法自 I2C-Inject i2c_sniff_decode.py,
在真机总线上验证过, 引脚编码沿用: val bit0=SDA bit1=SCL);
- 事务 -> 边沿序列的参考编码器 encode_edges()(自测向量生成, 也规定了对齐时序);
- 事务显示行/CSV 导出。
时间单位一律 us(int)。地址为 7bit。
"""
# ---------------------------------------------------------------------------
# 领域模型
# ---------------------------------------------------------------------------
class Transaction:
"""一次 I2C 事务: START .. (STOP | repeated START | 流被截断)
字段:
t0_us / t1_us START 边沿时刻 / 收尾边沿时刻(STOP/rep-START/最后边沿)
addr7 7bit 地址
read True=读(R), False=写(W)
acks 每字节一个应答, True=ACK(SDA=0), [0] 是地址字节应答
data 地址字节之后的数据字节(bytes)
ended 收尾方式: 'stop' / 'rstart' / 'cut'(流截断) / 'partial'(字节中途断)
truncated 固件长事务截断(data/acks 只含前段, 尾部丢弃)
"""
__slots__ = ('t0_us', 't1_us', 'addr7', 'read', 'acks', 'data', 'ended',
'truncated')
def __init__(self, t0_us=0, addr7=0, read=False, acks=(), data=b'',
ended='stop', t1_us=None, truncated=False):
self.t0_us = int(t0_us)
self.t1_us = int(t0_us if t1_us is None else t1_us)
self.addr7 = int(addr7)
self.read = bool(read)
self.acks = tuple(bool(a) for a in acks)
self.data = bytes(data)
self.ended = ended
self.truncated = bool(truncated)
# --- 便捷属性 ---
@property
def addr_ack(self):
return self.acks[0] if self.acks else None
@property
def data_acks(self):
return self.acks[1:]
@property
def complete(self):
return self.ended == 'stop'
@property
def dur_us(self):
return self.t1_us - self.t0_us
def key(self):
"""解码自测用等值比较(不含时间戳的不变内容)"""
return (self.addr7, self.read, self.acks, self.data, self.ended)
# --- 显示 ---
def addr_hex(self):
return f'0x{self.addr7:02X}'
def rw_char(self):
return 'R' if self.read else 'W'
def ack_str(self):
return ''.join('A' if a else 'N' for a in self.acks) or '-'
def flag_str(self):
return {'stop': '', 'rstart': '+', 'cut': ' <未收尾>',
'partial': ' <半字节断>'}.get(self.ended, '')
def data_hex(self, limit=0):
"""数据 hex; limit>0 时截断并加省略号"""
h = ' '.join(f'{b:02X}' for b in self.data)
if limit and len(self.data) > limit:
h = ' '.join(f'{b:02X}' for b in self.data[:limit]) + ' …'
return h
def format(self, idx=None, with_dur=True):
"""单行显示: [idx] t=<t0>us <dur>us 0x32 W: FF 60 ACK:AA"""
head = f'[{idx:3d}] ' if idx is not None else ''
dur = f' {self.dur_us:>6d}us' if with_dur else ''
body = self.data_hex(12) if self.data else '(无数据)'
return (f'{head}t={self.t0_us:9d}us{dur} {self.addr_hex()} '
f'{self.rw_char()}: {body:<30s} ACK:{self.ack_str()}'
f'{self.flag_str()}')
def __repr__(self):
return (f'Transaction(t0={self.t0_us}, addr=0x{self.addr7:02X}, '
f'{self.rw_char()}, data={self.data_hex()}, acks={self.ack_str()}, '
f'ended={self.ended})')
# ---------------------------------------------------------------------------
# 边沿序列 -> 事务(算法自 I2C-Inject i2c_sniff_decode.py, 真机验证过)
# ---------------------------------------------------------------------------
def decode_edges(edges):
"""边沿序列 -> [Transaction]。
edges: 可迭代的 (t_us, pins), pins bit0=SDA bit1=SCL, 值为新电平(变化沿)。
首个边沿只用于建立基线电平。规则:
SCL 高电平期间 SDA 下降 = START/repeated START(收掉当前事务, 新开一个);
SCL 高电平期间 SDA 上升 = STOP;
SCL 上升沿采样 SDA 为数据位, 第 9 位为应答(0=ACK)。
事务由 START..STOP 组成; repeated START 结束上一段(ended='rstart');
输入流耗尽仍未收尾的按 'cut'(若正好断在半字节则 'partial')。
"""
return _decode_impl(edges)
def _decode_impl(edges):
"""decode_edges 的真实实现(按位状态机, 与旧脚本语义一致)"""
trans = []
cur = None # {'t0', 'bytes'[], 'acks'[], }
bits = []
psda = pscl = None
t_last = 0
def close(t, ended):
nonlocal cur, bits
if cur is not None:
if bits and ended == 'cut':
ended = 'partial'
d = cur['bytes']
trans.append(Transaction(
t0_us=cur['t0'], t1_us=t, addr7=(d[0] >> 1) if d else 0,
read=bool(d[0] & 1) if d else False,
acks=cur['acks'], data=bytes(d[1:]), ended=ended))
cur = None
bits = []
for t, v in edges:
t_last = t
sda, scl = v & 1, (v >> 1) & 1
if pscl is not None:
if scl == 1 and pscl == 1: # SCL 高电平期间 SDA 变化
if psda == 1 and sda == 0: # START / repeated START
close(t, 'rstart')
cur = {'t0': t, 'bytes': [], 'acks': []}
elif psda == 0 and sda == 1: # STOP
close(t, 'stop')
elif scl == 1 and pscl == 0: # SCL 上升沿采样
if cur is not None:
bits.append(sda)
if len(bits) == 8:
cur['bytes'].append(
int(''.join(map(str, bits)), 2))
elif len(bits) == 9: # 第 9 位 = 应答(0=ACK)
cur['acks'].append(sda == 0)
bits = [] # 8 数据位不清, 凑满 9 位才清
psda, pscl = sda, scl
close(t_last, 'cut')
return trans
# ---------------------------------------------------------------------------
# 事务 -> 边沿序列(参考编码器: 自测向量生成 + 对齐时序规定)
# ---------------------------------------------------------------------------
class EdgeEncoder:
"""把事务序列编码成 (t_us, pins) 边沿流。pins bit0=SDA bit1=SCL。
定时(khz=100 → half=5us): SDA 在 SCL 变低后 1us 换位, SCL 高/低各
half us。时间戳与 decode_edges 的采样规则严格对齐:
START 的 t0 = SDA 下降沿时刻, STOP 的 t1 = SDA 上升沿时刻。
"""
def __init__(self, khz=100.0, t0_us=1000):
self.half = max(1, round(500.0 / khz)) # 半周期 us
self.t = int(t0_us)
self.sda = 1
self.scl = 1
self.edges = [(self.t, self.pins())] # 初始基线电平
def pins(self):
return (self.scl << 1) | self.sda
def _set(self, sda=None, scl=None, dt=1):
"""电平目标: 只有真发生变化的沿才推进时间并记录(边沿流语义)"""
if sda is not None and sda != self.sda:
self.t += dt
self.sda = sda
self.edges.append((self.t, self.pins()))
if scl is not None and scl != self.scl:
self.t += dt
self.scl = scl
self.edges.append((self.t, self.pins()))
def _bit(self, level):
"""发一位: SCL 低电平窗口内置 SDA=level, SCL 走一拍(高 half 低 half)"""
self._set(sda=level, scl=0)
self._set(scl=1, dt=self.half)
self._set(scl=0, dt=self.half)
def start(self):
"""(repeated) START: SDA 高->低(SCL 高, t0 基准沿), 随后 SCL 拉低"""
self._set(sda=1)
self._set(scl=1)
self._set(sda=0, dt=self.half) # t0 基准沿
t0 = self.t
self._set(scl=0, dt=self.half)
return t0
def stop(self):
"""STOP: SCL 低时 SDA 置低, SCL 升高, SDA 升高(t1 基准沿)"""
self._set(sda=0, scl=0)
self._set(scl=1, dt=self.half)
self._set(sda=1, dt=self.half) # t1 基准沿
return self.t
def byte(self, value, ack):
"""发 8 数据位 + 1 应答位(ack=True 为 ACK, 即 SDA=0)"""
for i in range(7, -1, -1):
self._bit((value >> i) & 1)
self._bit(0 if ack else 1)
return self.t
def encode_edges(transactions, khz=100.0, t0_us=1000, idle_us=50):
"""事务列表 -> (edges, refs)。事务间 SDA/SCL 保持高(总线空闲 idle_us)。
ended 字段决定收尾: 'stop' 发 STOP; 'rstart' 直接进入下一个事务的
repeated START; 'cut'/'partial' 发完最后字节悬停(仅自测用)。
refs[i] = (t0, t1): t0 恒有; t1 仅 'stop' 时有(STOP 沿时刻, 与解码器
对 t1 的定义一致), 其余为 None。
"""
enc = EdgeEncoder(khz=khz, t0_us=t0_us)
refs = []
for k, tr in enumerate(transactions):
t0 = enc.start()
enc.byte(tr.addr7 << 1 | (1 if tr.read else 0), tr.acks[0])
for i, b in enumerate(tr.data):
ack = tr.acks[i + 1] if i + 1 < len(tr.acks) else True
enc.byte(b, ack)
t1 = enc.stop() if tr.ended == 'stop' else None
if tr.ended == 'cut':
enc._set(scl=0)
refs.append((t0, t1))
if k + 1 < len(transactions):
enc.t += idle_us # 空闲间隙(不出边沿)
return enc.edges, refs
# ---------------------------------------------------------------------------
# 固件监控流行解析(T/W 行, 语法见 firmware/main.c)
# ---------------------------------------------------------------------------
def to_tline(tr, idx=None):
"""Transaction -> 固件 T 行语法(本函数是语法权威之一, 与固件对齐):
T <t0_us> <dur_us> <addr7:02X> <R|W> <flags> <acks> <n> <b1..bn>
flags: 结束码 + 可选 'L'(长事务截断): '-' 'r' 'c' 'p' ( + 'L' )
acks : n+1 个 A/N, 首个是地址应答; n=0 时地址应答仍保留
"""
flags = {'stop': '-', 'rstart': 'r', 'cut': 'c', 'partial': 'p'}[tr.ended]
if tr.truncated:
flags += 'L'
n = len(tr.data)
body = ' '.join(f'{b:02X}' for b in tr.data)
return (f'T {tr.t0_us} {tr.dur_us} {tr.addr7:02X} {tr.rw_char()} '
f'{flags} {tr.ack_str()} {n} {body}'.rstrip())
def parse_tline(line):
"""固件 T 行 -> Transaction; 非法行抛 ValueError。"""
parts = line.split()
if len(parts) < 7 or parts[0] != 'T':
raise ValueError(f'非 T 行: {line!r}')
try:
t0, dur = int(parts[1]), int(parts[2])
addr = int(parts[3], 16)
rw = parts[4]
flags = parts[5]
acks_s = parts[6]
n = int(parts[7])
data = bytes(int(x, 16) for x in parts[8:8 + n])
except (IndexError, ValueError) as e:
raise ValueError(f'T 行字段非法: {line!r} ({e})') from e
if len(data) != n:
raise ValueError(f'T 行数据不足: 声明 {n} 实得 {len(data)}')
if rw not in ('R', 'W'):
raise ValueError(f'T 行方向非法: {rw!r}')
if not flags or len(flags) > 2 or flags[0] not in '-rcp':
raise ValueError(f'T 行 flags 非法: {flags!r}')
truncated = 'L' in flags[1:]
ended = {'-': 'stop', 'r': 'rstart', 'c': 'cut', 'p': 'partial'}[flags[0]]
if len(acks_s) != n + 1 or set(acks_s) - {'A', 'N'}:
raise ValueError(f'T 行 acks 长度/字符非法: {acks_s!r}')
return Transaction(t0_us=t0, t1_us=t0 + dur, addr7=addr, read=(rw == 'R'),
acks=tuple(c == 'A' for c in acks_s), data=data,
ended=ended, truncated=truncated)
def parse_wline(line):
"""固件 W 行(边沿快照) -> (t_us, pins); 'W.' 结尾行返回 None。"""
if line == 'W.':
return None
parts = line.split()
if len(parts) != 3 or parts[0] != 'W':
raise ValueError(f'非 W 行: {line!r}')
t, pins = int(parts[1]), int(parts[2])
if not 0 <= pins <= 3:
raise ValueError(f'W 行 pins 越界: {pins}')
return (t, pins)
# ---------------------------------------------------------------------------
# 导出
# ---------------------------------------------------------------------------
def transactions_to_csv(transactions):
"""事务列表 -> CSV 文本(含表头; 数据列 hex 串, 应答列 A/N 串)"""
rows = ['idx,t0_us,dur_us,addr7,rw,addr_ack,data_hex,data_acks,ended']
for i, tr in enumerate(transactions):
rows.append(','.join((
str(i), str(tr.t0_us), str(tr.dur_us), f'{tr.addr7:02X}',
tr.rw_char(), 'A' if tr.addr_ack else 'N',
tr.data_hex(), ''.join('A' if a else 'N' for a in tr.data_acks),
tr.ended)))
return '\n'.join(rows) + '\n'
+582
View File
@@ -0,0 +1,582 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
I2C-Lab — I2C 总线监控调试台(Fluent UI)
==================================================
RP2040 probe 固件(被动监听)的上位机: 实时事务流 + 逻辑分析仪式波形快照。
功能:
- 串口连接固件(USB CDC), 一键开/停监控流(T 行)
- 事务表: 时间/时长/地址/RW/应答/数据/收尾标志, 地址过滤, 暂停滚动,
清空, CSV 导出
- 波形卡: wave 快照渲染 SDA/SCL 时序 + 事务注记(滚轮缩放/拖动/双击适配)
- 固件计数统计(边沿/事务/丢包); 演示数据模式(无硬件看界面)
- Light/Dark/跟随系统主题(DLPilot/SerialMate 三件套)
用法:
python main.py 打开 GUI
python main.py --seconds N N 秒后自动退出(冒烟测试)
python main.py --demo 启动即开演示数据
"""
import argparse
import os
import queue
import random
import sys
import time
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QColor, QIcon
from PySide6.QtWidgets import (QApplication, QFileDialog, QHBoxLayout,
QHeaderView, QMainWindow, QMessageBox,
QSplitter, QTableWidget, QTableWidgetItem,
QVBoxLayout, QWidget)
from qfluentwidgets import (BodyLabel, CaptionLabel, CardWidget, CheckBox,
ComboBox, FluentIcon, LineEdit, PlainTextEdit,
PrimaryPushButton, PushButton, SpinBox,
StrongBodyLabel, SubtitleLabel)
import config
import i2cdec
from i2cdec import Transaction
from probelink import ProbeLink
from theme import ThemeManager
from waveview import WaveView
APP_NAME = 'I2C-Lab'
APP_VERSION = '0.1.0'
REPO_URL = 'https://gitea.hulk.wang/hulk/HW-Debug-Tools'
TABLE_CAP = 5000 # 表格行数上限(超出从顶删)
STORE_CAP = 20000 # CSV 导出保留的事务上限
WAVE_N_DEFAULT = 2048
WAVE_N_MAX = 16384
def _now_ms():
return int(time.time() * 1000)
class Window(QMainWindow):
def __init__(self, theme_manager=None, seconds=0, demo=False):
super().__init__()
self.setWindowTitle(f'{APP_NAME} · I2C 总线监控调试台 v{APP_VERSION}')
self.resize(1380, 860)
self.setMinimumSize(1040, 640)
self.theme_manager = theme_manager or ThemeManager(QApplication.instance())
self.cfg_store = config.load()
self.events = queue.Queue()
self.transactions = [] # 全量事务(CSV 用, 环形)
self._trans_total = self._trans_shown = 0
self._fw_stats = ''
self._demo_edges = [] # 演示模式的滚动边沿流
self._demo_t = 100_000
self._paused = False
self.link = ProbeLink(on_status=lambda s: self.events.put(('status', s)))
self.link.on_trans = lambda tr: self.events.put(('trans', tr))
self.link.on_wave = lambda e, d: self.events.put(('wave', e, d))
self.link.on_info = lambda l: self.events.put(('info', l))
self._build_ui()
self._apply_store_to_ui()
self.theme_manager.set_window(self)
poll = QTimer(self)
poll.timeout.connect(self._drain_events)
poll.start(30)
self._demo_timer = QTimer(self)
self._demo_timer.timeout.connect(self._demo_tick)
if demo:
self._toggle_demo()
if seconds:
QTimer.singleShot(int(seconds * 1000), self.close)
# ------------------------------------------------------------------ UI
def _build_ui(self):
root = QWidget(self)
root.setObjectName('root')
outer = QVBoxLayout(root)
outer.setContentsMargins(18, 12, 18, 12)
outer.setSpacing(10)
head = QHBoxLayout()
box = QVBoxLayout()
box.setSpacing(1)
box.addWidget(SubtitleLabel(APP_NAME))
box.addWidget(CaptionLabel(
f'I2C 总线监控调试台 · RP2040 被动监听 · v{APP_VERSION}'))
head.addLayout(box)
head.addStretch(1)
self.btn_demo = PushButton('演示数据')
self.btn_demo.setToolTip('无硬件演示: 模拟 LT8619C 轮询流量(表格/波形全链路)')
self.btn_demo.clicked.connect(self._toggle_demo)
head.addWidget(self.btn_demo)
btn_about = PushButton('关于')
btn_about.clicked.connect(self.show_about)
head.addWidget(btn_about)
self.combo_theme = ComboBox()
for m in ('跟随系统', '浅色', '深色'):
self.combo_theme.addItem(m)
self.combo_theme.setCurrentIndex(0)
self.combo_theme.currentIndexChanged.connect(self._on_theme)
head.addWidget(BodyLabel('主题'))
head.addWidget(self.combo_theme)
outer.addLayout(head)
splitter = QSplitter(Qt.Horizontal)
left, right = QWidget(), QWidget()
lcol, rcol = QVBoxLayout(left), QVBoxLayout(right)
lcol.setContentsMargins(0, 0, 0, 0)
rcol.setContentsMargins(0, 0, 0, 0)
lcol.setSpacing(10)
rcol.setSpacing(10)
self._build_serial_card(lcol)
self._build_mon_card(lcol)
self._build_log_card(lcol)
lcol.addStretch(1)
rsplit = QSplitter(Qt.Vertical)
rsplit.setHandleWidth(6)
rsplit.setChildrenCollapsible(False)
rsplit.addWidget(self._build_table_card())
rsplit.addWidget(self._build_wave_card())
rsplit.setStretchFactor(0, 1)
rsplit.setStretchFactor(1, 1)
rsplit.setSizes((420, 320))
rcol.addWidget(rsplit)
splitter.addWidget(left)
splitter.addWidget(right)
splitter.setSizes((400, 960))
outer.addWidget(splitter, 1)
self.setCentralWidget(root)
self.statusBar().showMessage('未连接')
def _card(self, title, subtitle=None, side=False):
card = CardWidget(self)
lay = QVBoxLayout(card)
lay.setContentsMargins(14, 12, 14, 14)
lay.setSpacing(8)
if subtitle and side:
h = QHBoxLayout()
h.setSpacing(10)
h.addWidget(StrongBodyLabel(title, card))
h.addStretch(1)
h.addWidget(CaptionLabel(subtitle, card))
lay.addLayout(h)
return card, lay
lay.addWidget(StrongBodyLabel(title, card))
if subtitle:
c = CaptionLabel(subtitle, card)
c.setWordWrap(True)
lay.addWidget(c)
return card, lay
@staticmethod
def _row(lay, *widgets, trailing=True):
h = QHBoxLayout()
h.setSpacing(6)
for w in widgets:
h.addWidget(w)
if trailing:
h.addStretch(1)
lay.addLayout(h)
return h
# --- 串口卡 ---
def _build_serial_card(self, col):
card, lay = self._card('固件串口', 'USB CDC, 参数固定; [RP2040] 标记为建议端口',
side=True)
self.combo_port = ComboBox()
self.combo_port.setMinimumWidth(170)
self._port_devs = []
self.btn_refresh = PushButton('刷新')
self.btn_refresh.setIcon(FluentIcon.SYNC)
self.btn_refresh.clicked.connect(self._refresh_ports)
self.btn_conn = PrimaryPushButton('连接')
self.btn_conn.clicked.connect(self._toggle_connect)
h = self._row(lay, self.combo_port, self.btn_refresh, self.btn_conn,
trailing=False)
h.setStretch(0, 1)
self.lb_state = BodyLabel('未连接')
self.lb_state.setWordWrap(True)
lay.addWidget(self.lb_state)
col.addWidget(card)
self._refresh_ports()
def _refresh_ports(self):
self._port_devs = ProbeLink.list_ports_info()
combo = self.combo_port
combo.clear()
for _dev, label in self._port_devs:
combo.addItem(label)
if not self._port_devs:
combo.addItem('未检测到串口')
def _toggle_connect(self):
if self.link.is_connected():
self.link.close()
self.btn_conn.setText('连接')
return
if not self._port_devs:
self._hint('未检测到串口, 插入 probe 后刷新')
return
idx = self.combo_port.currentIndex()
if not 0 <= idx < len(self._port_devs):
self._hint('请选择串口')
return
self.link.open(self._port_devs[idx][0])
self.btn_conn.setText('断开')
# --- 监控卡 ---
def _build_mon_card(self, col):
card, lay = self._card('监控', 'probe 纯被动监听: 不加线上拉, 不驱动总线')
self.btn_mon = PrimaryPushButton('开始监控')
self.btn_mon.setIcon(FluentIcon.VIDEO)
self.btn_mon.clicked.connect(self._toggle_mon)
self.btn_pause = PushButton('暂停滚动')
self.btn_pause.clicked.connect(self._toggle_pause)
btn_clear = PushButton('清空')
btn_clear.setIcon(FluentIcon.DELETE)
btn_clear.clicked.connect(self._clear_table)
self.btn_csv = PushButton('导出CSV')
self.btn_csv.setIcon(FluentIcon.SAVE)
self.btn_csv.clicked.connect(self._save_csv)
self._row(lay, self.btn_mon, self.btn_pause, btn_clear, self.btn_csv)
self.ed_filter = LineEdit()
self.ed_filter.setPlaceholderText('如 32 50(十六进制, 空格分隔; 空=全部)')
self.ed_filter.setFixedWidth(200)
self.lb_filter = CaptionLabel('')
self._row(lay, CaptionLabel('地址过滤'), self.ed_filter, self.lb_filter)
self.lb_stats = CaptionLabel('事务 0 | 表内 0')
self.lb_stats.setWordWrap(True)
lay.addWidget(self.lb_stats)
self.lb_fw = CaptionLabel('固件计数: 未连接')
self.lb_fw.setWordWrap(True)
lay.addWidget(self.lb_fw)
col.addWidget(card)
def _toggle_mon(self):
if self._demo_timer.isActive():
self._hint('演示模式运行中, 先停演示再连真机')
return
on = self.btn_mon.text().startswith('开始')
self.link.send_line('mon start' if on else 'mon stop')
self.btn_mon.setText('停止监控' if on else '开始监控')
def _toggle_pause(self):
self._paused = not self._paused
self.btn_pause.setText('继续滚动' if self._paused else '暂停滚动')
def _addr_filter(self):
"""过滤输入 -> {addr7:int} 集合; 非法输入提示并返回 None"""
text = self.ed_filter.text().strip()
if not text:
self.lb_filter.setText('')
return None
out = set()
for tok in text.replace(',', ' ').split():
try:
out.add(int(tok, 16))
except ValueError:
self.lb_filter.setText(f'忽略非法项 {tok!r}')
return out
def _clear_table(self):
self.table.setRowCount(0)
self._trans_shown = 0
self._update_stats()
def _save_csv(self):
path, _ = QFileDialog.getSaveFileName(self, '导出事务 CSV',
'i2c_transactions.csv',
'CSV 文件 (*.csv)')
if not path:
return
try:
with open(path, 'w', encoding='utf-8-sig') as f:
f.write(i2cdec.transactions_to_csv(self.transactions))
self._hint(f'已导出 {len(self.transactions)} 条事务: {path}')
except OSError as e:
self._hint(f'导出失败: {e}')
# --- 固件日志卡 ---
def _build_log_card(self, col):
card, lay = self._card('固件消息', side=True)
self.text_log = PlainTextEdit()
self.text_log.setReadOnly(True)
self.text_log.setFont(self._mono_font())
self.text_log.document().setMaximumBlockCount(800)
self.text_log.setMinimumHeight(88)
self.text_log.setMaximumHeight(150)
lay.addWidget(self.text_log)
col.addWidget(card)
@staticmethod
def _mono_font():
from PySide6.QtGui import QFontDatabase
return QFontDatabase.systemFont(QFontDatabase.FixedFont)
# --- 事务表卡 ---
def _build_table_card(self):
card, lay = self._card('事务流', 'T 行实时解码: 每行一个 START..收尾 的事务')
self.table = QTableWidget(0, 8)
self.table.setHorizontalHeaderLabels(
['#', 't0(us)', '时长us', '地址', 'RW', 'ACK', '数据', '收尾'])
hdr = self.table.horizontalHeader()
hdr.setSectionResizeMode(0, QHeaderView.ResizeToContents)
for c in (1, 2, 3, 4):
hdr.setSectionResizeMode(c, QHeaderView.ResizeToContents)
hdr.setSectionResizeMode(5, QHeaderView.ResizeToContents)
hdr.setSectionResizeMode(6, QHeaderView.Stretch)
hdr.setSectionResizeMode(7, QHeaderView.ResizeToContents)
self.table.verticalHeader().setVisible(False)
self.table.verticalHeader().setDefaultSectionSize(26)
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
self.table.setSelectionBehavior(QTableWidget.SelectRows)
self.table.setAlternatingRowColors(True)
self.table.setFont(self._mono_font())
lay.addWidget(self.table, 1)
return card
def _add_trans_row(self, tr: Transaction, idx: int):
t = self.table
t.insertRow(t.rowCount())
r = t.rowCount() - 1
ended_label = {'stop': 'STOP', 'rstart': '重START', 'cut': '截断',
'partial': '半字节'}[tr.ended]
if tr.truncated:
ended_label += '+截长'
data_str = tr.data_hex(16)
cells = (str(idx), str(tr.t0_us), str(tr.dur_us), tr.addr_hex(),
tr.rw_char(), tr.ack_str(), data_str or '—', ended_label)
for c, txt in enumerate(cells):
it = QTableWidgetItem(txt)
if c == 6 and len(tr.data) > 16:
it.setToolTip(tr.data_hex())
if c == 5 and tr.acks and not tr.addr_ack:
it.setForeground(QColor('#e0564c')) # 地址 NAK 标红
if c == 7 and tr.ended not in ('stop', 'rstart'):
it.setForeground(QColor('#e08a2e'))
it.setFlags(it.flags() & ~Qt.ItemIsEditable)
t.setItem(r, c, it)
if t.rowCount() > TABLE_CAP:
t.removeRow(0)
if not self._paused:
t.scrollToBottom()
# --- 波形卡 ---
def _build_wave_card(self):
card, lay = self._card('波形快照',
'逻辑分析仪视图: 滚轮缩放(光标锚点) · 拖动平移 · '
'双击适配; 绿/红虚线=START/STOP')
self.wave = WaveView()
lay.addWidget(self.wave, 1)
self.spin_wave_n = SpinBox()
self.spin_wave_n.setRange(256, WAVE_N_MAX)
self.spin_wave_n.setSingleStep(256)
self.spin_wave_n.setValue(WAVE_N_DEFAULT)
self.btn_wave = PrimaryPushButton('抓取波形')
self.btn_wave.setIcon(FluentIcon.CAMERA)
self.btn_wave.clicked.connect(self._grab_wave)
btn_fit = PushButton('适配全宽')
btn_fit.clicked.connect(self.wave.fit_all)
self.lb_wave = CaptionLabel('未抓取')
h = self._row(lay, self.btn_wave, CaptionLabel('边沿数'), self.spin_wave_n,
btn_fit, self.lb_wave)
h.setStretch(3, 0)
return card
def _grab_wave(self):
n = self.spin_wave_n.value()
if self._demo_timer.isActive():
edges = self._demo_edges[-n:]
self._on_wave(edges, 0)
return
self.link.send_line(f'wave {n}')
self.lb_wave.setText(f'请求 {n} 边沿…')
# ------------------------------------------------------- 演示模式
def _toggle_demo(self):
if self._demo_timer.isActive():
self._demo_timer.stop()
self.btn_demo.setText('演示数据')
self._hint('演示已停止')
else:
self._demo_timer.start(300)
self.btn_demo.setText('停止演示')
self._hint('演示模式: 模拟 LT8619C demo 板 ~500ms 轮询流量')
def _demo_tick(self):
"""模拟 demo 板流量: 每 tick 生成一批事务, 同时积累边沿供波形抓取"""
rng = random.Random()
fam = []
bank = rng.choice((0x60, 0x61))
reg = rng.choice((0x6E, 0x6D, 0x44))
fam.append(Transaction(addr7=0x32, read=False, acks=(True, True, True),
data=bytes((0xFF, bank))))
fam.append(Transaction(addr7=0x32, read=False,
acks=(True, True) + ((True,) if rng.random() < 0.7 else ()),
data=bytes((reg, rng.randrange(256)))
if rng.random() < 0.7 else bytes((reg,))))
if rng.random() < 0.4: # 偶尔一次读回链
fam.append(Transaction(addr7=0x32, read=False, acks=(True, True),
data=bytes((reg,)), ended='rstart'))
fam.append(Transaction(addr7=0x32, read=True, acks=(True, False),
data=bytes((rng.randrange(256),))))
edges, _refs = i2cdec.encode_edges(fam, khz=100.0, t0_us=self._demo_t)
shift = edges[0][0] - self._demo_t
edges = [(t - shift + self._demo_t, v) for t, v in edges]
self._demo_t = edges[-1][0] + 500_000 # ~500ms 轮询间隔
self._demo_edges.extend(edges)
if len(self._demo_edges) > 6 * WAVE_N_MAX:
del self._demo_edges[:len(self._demo_edges) - 4 * WAVE_N_MAX]
for tr in i2cdec.decode_edges(edges):
self.events.put(('trans', tr))
# ------------------------------------------------------- 事件
def _drain_events(self):
for _ in range(500):
try:
ev = self.events.get_nowait()
except queue.Empty:
return
kind = ev[0]
if kind == 'status':
self._on_status(ev[1])
elif kind == 'trans':
self._on_trans(ev[1])
elif kind == 'wave':
self._on_wave(ev[1], ev[2])
elif kind == 'info':
self._on_info(ev[1])
def _hint(self, text):
self.lb_state.setText(text)
self.statusBar().showMessage(text, 8000)
self.text_log.appendPlainText(f'· {text}')
def _on_status(self, text):
if text.startswith('已连接'):
self.btn_conn.setText('断开')
self.link.send_line('v')
self.link.send_line('stats')
elif text.startswith(('已断开', '连接失败', '串口丢失')):
self.btn_conn.setText('连接')
self._hint(text)
def _on_trans(self, tr):
self._trans_total += 1
self.transactions.append(tr)
if len(self.transactions) > STORE_CAP:
del self.transactions[:len(self.transactions) - STORE_CAP]
flt = self._addr_filter()
if flt is not None and tr.addr7 not in flt:
self._update_stats()
return
self._trans_shown += 1
self._add_trans_row(tr, self._trans_total)
self._update_stats()
def _on_wave(self, edges, dropped):
self.wave.set_edges(edges)
n = len(edges)
try:
trans = i2cdec.decode_edges(edges)
except Exception:
trans = []
msg = f'{n} 边沿, 解码 {len(trans)} 事务'
if dropped:
msg += f', 缺 {dropped}'
self.lb_wave.setText(msg)
self._hint(f'波形快照: {msg}')
def _on_info(self, line):
self.text_log.appendPlainText(line)
if line.startswith('= stats '):
self._fw_stats = line[len('= stats '):]
self.lb_fw.setText(f'固件计数: {self._fw_stats}')
def _update_stats(self):
self.lb_stats.setText(f'事务 {self._trans_total} | 表内 {self._trans_shown}'
f' | 导出池 {len(self.transactions)}')
self.statusBar().showMessage(
f'{APP_NAME} v{APP_VERSION} 事务 {self._trans_total}')
def _on_theme(self, idx):
self.theme_manager.set_mode(('system', 'light', 'dark')[idx])
def show_about(self):
QMessageBox.about(
self, f'关于 {APP_NAME}',
f'{APP_NAME} v{APP_VERSION}\n\n'
'I2C 总线监控调试台 · RP2040 被动监听(PIO 捕获 + 实时解码)\n'
'实时事务流 + 逻辑分析仪式波形快照 + CSV 导出\n'
'接线: GP8=SDA GP9=SCL 共地(纯被动, 不加上拉)\n\n'
'PySide6-Fluent-Widgets + pyserial · 固件 pico-sdk C\n'
f'{REPO_URL}')
# ------------------------------------------------------- 配置进出
def _apply_store_to_ui(self):
m = self.cfg_store['mon']
self.ed_filter.setText(m.get('addr_filter', ''))
self.spin_wave_n.setValue(int(m.get('wave_n', WAVE_N_DEFAULT)))
s = self.cfg_store['serial']
if s.get('port'):
for i, (dev, _label) in enumerate(self._port_devs):
if dev == s['port']:
self.combo_port.setCurrentIndex(i)
break
def _store_from_ui(self):
idx = self.combo_port.currentIndex()
port = self._port_devs[idx][0] if 0 <= idx < len(self._port_devs) else ''
return {
'serial': {'port': port},
'mon': {'addr_filter': self.ed_filter.text().strip(),
'paused': self._paused,
'wave_n': self.spin_wave_n.value()},
}
def closeEvent(self, ev):
self._demo_timer.stop()
self.link.shutdown() # join 工作线程, 防解释器关闭期崩溃
config.save(self._store_from_ui())
super().closeEvent(ev)
def icon_path():
"""应用图标: exe(onefile)在 _MEIPASS/assets, 源码运行在工具根 assets/"""
if hasattr(sys, '_MEIPASS'):
return os.path.join(sys._MEIPASS, 'assets', 'i2c-lab.ico')
return os.path.join(os.path.dirname(os.path.abspath(__file__)),
'..', 'assets', 'i2c-lab.ico')
def main():
ap = argparse.ArgumentParser(description='I2C-Lab I2C 总线监控调试台')
ap.add_argument('--seconds', type=float, default=0,
help='N 秒后自动退出(冒烟测试)')
ap.add_argument('--demo', action='store_true', help='启动即开演示数据')
args = ap.parse_args()
app = QApplication(sys.argv)
app.setWindowIcon(QIcon(icon_path()))
win = Window(seconds=args.seconds, demo=args.demo)
win.show()
sys.exit(app.exec())
if __name__ == '__main__':
main()
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
组装 I2C-Lab 发布包: exe + 固件 UF2 + README → 一个 zip(解压即用)
================================================
把主 README 与 probe 固件 UF2 复制进 release/ 后, 与单文件 exe 一起压 zip。
zip 解压后根目录为 I2C-Lab/:
I2C-Lab.exe 主程序(先运行 build_exe.py 打包)
firmware/i2c_lab.uf2 probe 固件(BOOTSel 拖入刷写)
README.md 使用说明
用法:
python package_release.py 版本号自动取最近 git tag(I2C-Lab-v*)
python package_release.py 0.1.0 手动指定版本号
产物: release/I2C-Lab-v<版本>-win64.zip(zip 不入库, 上传到 Gitea Release)
"""
import os
import shutil
import subprocess
import sys
import zipfile
SRC = os.path.dirname(os.path.abspath(__file__))
BASE = os.path.dirname(SRC)
RELEASE = os.path.join(BASE, 'release')
def detect_version():
"""从最近的 I2C-Lab-v* tag 推断版本号, 无 tag 则报错提示手动指定"""
try:
out = subprocess.check_output(
['git', 'describe', '--tags', '--abbrev=0', '--match', 'I2C-Lab-v*'],
cwd=BASE, stderr=subprocess.DEVNULL)
return out.decode().strip().removeprefix('I2C-Lab-v')
except Exception:
sys.exit('未能从 git tag 推断版本号, 请手动指定: python package_release.py <版本号>')
def main():
version = (sys.argv[1] if len(sys.argv) > 1 else detect_version()).lstrip('v')
exe = os.path.join(RELEASE, 'I2C-Lab.exe')
readme = os.path.join(BASE, 'README.md')
uf2 = os.path.join(RELEASE, 'firmware', 'i2c_lab.uf2')
if not os.path.exists(exe):
sys.exit('缺少 release/I2C-Lab.exe, 先运行: python build_exe.py')
if not os.path.exists(readme):
sys.exit(f'缺少 {readme}')
if not os.path.exists(uf2):
sys.exit('缺少 release/firmware/i2c_lab.uf2, 先按 firmware/CMakeLists 注释构建')
shutil.copy2(readme, os.path.join(RELEASE, 'README.md'))
zip_name = f'I2C-Lab-v{version}-win64.zip'
zip_path = os.path.join(RELEASE, zip_name)
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z:
z.write(exe, f'I2C-Lab/{os.path.basename(exe)}')
z.write(uf2, 'I2C-Lab/firmware/i2c_lab.uf2')
z.write(os.path.join(RELEASE, 'README.md'), 'I2C-Lab/README.md')
print(f'发布文件夹: {RELEASE} (exe + firmware + README)')
print(f'发布包: {zip_path} ({os.path.getsize(zip_path) / 1024 / 1024:.1f} MB)')
print('下一步上传: python publish_gitea.py', zip_name)
if __name__ == '__main__':
main()
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
I2C-Lab probe 固件链路线程(USB CDC, 行协议)
==================================================
与 SerialMate link.py 同一套路(工作线程 + 意图驱动开合 + 回调投递), 差异:
- 固件是文本行协议(T 事务流 / W 边沿快照 / '=' 应答 / '!' 告警), 按行分发;
- USB CDC 不挑波特率(115200 仅占位), 参数固定 8N1;
- W 行成段接收: '= wave n' 头之后收集 n 条, 'W.' 收尾后一次性回调
(与固件 cmd_wave 输出顺序对齐)。
回调全在后台线程触发, GUI 侧必须投递回主线程(见 main.py 的事件队列)。
pyserial 为本模块独占依赖(缺失只影响实机链路)。
"""
import queue
import threading
import time
try:
import serial
import serial.tools.list_ports
HAS_SERIAL = True
except ImportError:
HAS_SERIAL = False
from i2cdec import parse_tline, parse_wline
VENDOR_HINTS = ('2e8a',) # RP2040 USB VID(枚举标签辅助, 不做过滤)
class ProbeLink:
"""固件串口工作线程。
回调(后台线程触发):
on_status(str) 连接状态变化
on_trans(Transaction) 一条 T 行解码出的事务
on_wave(edges, dropped) 一次 wave 快照完成: edges=[(t_us,pins)..]
on_info(line) '='/'!' 应答/告警行(原文)
"""
def __init__(self, on_status):
self.on_status = on_status
self.on_trans = None
self.on_wave = None
self.on_info = None
self.q = queue.Queue()
self._target = None # 意图 port
self._open_key = None
self.ser = None
self._lock = threading.Lock()
self._tx_q = queue.Queue()
self._wave = None # (n_expect, [edges..])
self._stop = threading.Event() # 退出信号(解释器关闭前必须 join)
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def shutdown(self):
"""退出用: 断开并 join 工作线程(防止解释器关闭期 daemon 线程访问违例)"""
self.close()
self._stop.set()
self._thread.join(timeout=2.0)
# --- 串口管理 ---
@staticmethod
def list_ports_info():
"""枚举串口 [(设备名, 显示标签)..]; RP2040 CDC 描述带 [RP2040] 提示"""
if not HAS_SERIAL:
return []
out = []
for p in serial.tools.list_ports.comports():
desc = (p.description or '').strip()
if desc.upper() == p.device.upper():
desc = ''
label = f'{p.device} {desc}'.rstrip()
if p.vid and f'{p.vid:04x}' in VENDOR_HINTS:
label += ' [RP2040]'
out.append((p.device, label))
return out
def open(self, port):
if not HAS_SERIAL:
self.on_status('未安装 pyserial: py -m pip install pyserial')
return
with self._lock:
self._target = port
def close(self):
with self._lock:
self._target = None
def is_connected(self):
with self._lock:
return self.ser is not None
# --- 发送(命令行) ---
def send_line(self, line):
"""发一行命令(自动补换行); 未连接时丢弃并提示"""
with self._lock:
ser = self.ser
if ser is None:
self.on_status('未连接, 命令被丢弃')
return False
self._tx_q.put(line.encode('ascii', 'replace') + b'\n')
return True
# --- 工作线程 ---
def _run(self):
buf = b''
while not self._stop.is_set():
with self._lock:
target = self._target
cur = self._open_key
if target != cur:
if self.ser is not None:
try:
self.ser.close()
except Exception:
pass
self.ser = None
self._open_key = None
self.on_status('已断开')
if target:
try:
self.ser = serial.Serial(target, 115200,
timeout=0.15,
write_timeout=1.0)
with self._lock:
self._open_key = target
self.on_status(f'已连接 {target}')
except Exception as e:
self.ser = None
with self._lock:
self._target = None
et = str(e)
if '拒绝访问' in et or 'Access is denied' in et:
et += '(串口被占用: 检查其他工具/终端)'
self.on_status(f'连接失败: {et}')
if self.ser is not None:
try:
try:
line = self._tx_q.get_nowait()
self.ser.write(line)
except queue.Empty:
pass
data = self.ser.read(4096)
if data:
buf = self._dispatch(buf + data)
except OSError:
try:
self.ser.close()
except Exception:
pass
self.ser = None
self._open_key = None
with self._lock:
self._target = None
self.on_status('串口丢失(设备拔出?), 请重新连接')
buf = b''
self._stop.wait(0.01) # 可被打断的 sleep(退出即醒)
def _dispatch(self, buf):
"""按行切分并分类; 返回残余半行缓冲"""
while True:
nl = buf.find(b'\n')
if nl < 0:
return buf
raw, buf = buf[:nl], buf[nl + 1:]
line = raw.decode('ascii', 'replace').rstrip('\r')
if not line:
continue
try:
if line.startswith('T '):
if self.on_trans is not None:
self.on_trans(parse_tline(line))
elif line.startswith('W '):
e = parse_wline(line)
if e is not None and self._wave is not None:
self._wave[1].append(e)
elif line == 'W.':
if self._wave is not None:
n_expect, edges = self._wave
self._wave = None
if self.on_wave is not None:
self.on_wave(edges, len(edges) - n_expect
if len(edges) < n_expect else 0)
elif line.startswith('= wave '):
try:
n = int(line.split()[2])
except (IndexError, ValueError):
n = 0
self._wave = (n, [])
else:
if self.on_info is not None:
self.on_info(line)
except ValueError as e:
# 坏行不致命: 记入状态提示并继续
if self.on_info is not None:
self.on_info(f'! 解析失败 {e}: {line[:60]!r}')
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
创建/更新 Gitea Release 并上传发布 zip(纯标准库, 无需安装依赖)
================================================================
用法:
python publish_gitea.py I2C-Lab-v0.1.0-win64.zip
python publish_gitea.py <zip> --notes-file release/notes-v0.1.0.md
python publish_gitea.py <zip> --tag I2C-Lab-v0.1.0 --replace
行为:
- tag 与版本默认取 zip 文件名(I2C-Lab-vX.Y.Z-win64.zip → I2C-Lab-vX.Y.Z)
- release 不存在则创建(tag 不存在时 Gitea 在默认分支头自动建 tag);
已存在则复用, 仅上传/更新资产
- release 上已有同名资产时: 默认跳过, --replace 删旧再传
- API token: --token 参数或环境变量 GITEA_TOKEN(需该仓库写权限)
- 本实例 TLS 间歇抽风: 已内置连接级重试, 实际发布请加 --insecure
"""
import argparse
import json
import os
import re
import ssl
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
DEFAULT_NOTES = """## I2C-Lab {version}
I2C 总线监控调试台(LT8619C 调试工具集配套): RP2040 probe 固件被动监听
I2C 总线(PIO 硬件捕获 + 实时解码, 不加上拉不驱动), PC 端实时事务流
(地址/RW/ACK/数据/收尾)、地址过滤、CSV 导出与逻辑分析仪式波形快照
(滚轮缩放/拖动/事务注记); 主机读写与从机模拟为后续计划。
### 安装
下载 `I2C-Lab-v{version}-win64.zip`, 解压后双击 `I2C-Lab.exe`
(Windows 10/11 x64, 免安装; 串口功能需系统可访问串口, 程序自带 pyserial)。
### 包内容
- `I2C-Lab.exe` — 主程序
- `firmware/i2c_lab.uf2` — probe 固件(按住 BOOTSEL 上电, 拖入 RPI-RP2 盘)
- `README.md` — 完整使用说明
"""
def git_remote_info():
"""从 origin 远程地址解析 API base 与 owner/repo(ssh 端口不影响 https API)"""
url = subprocess.check_output(['git', 'remote', 'get-url', 'origin']).decode().strip()
m = re.match(r'(?:ssh://git@|git@)([\w.-]+)(?::\d+)?[:/]([\w.-]+)/([\w.-]+?)(?:\.git)?$', url)
if not m:
sys.exit(f'无法从 origin 解析 Gitea 地址: {url}')
host, owner, repo = m.groups()
return f'https://{host}', owner, repo
class Api:
def __init__(self, base, owner, repo, token, insecure):
self.api = f'{base}/api/v1/repos/{owner}/{repo}'
self.headers = {'Authorization': f'token {token}'}
self.ctx = ssl._create_unverified_context() if insecure else None
def request(self, method, path, data=None, headers=None, retries=4):
"""发请求; 实例 TLS 间歇性抽风(SSL UNEXPECTED_EOF), 连接级错误自动重试"""
last = None
for attempt in range(retries):
req = urllib.request.Request(self.api + path, data=data, method=method,
headers={**self.headers, **(headers or {})})
try:
with urllib.request.urlopen(req, context=self.ctx) as resp:
body = resp.read()
return resp.status, json.loads(body) if body else None
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b'{}')
except (urllib.error.URLError, ssl.SSLError, OSError) as e:
last = e
time.sleep(1.0 + attempt)
raise RuntimeError(f'Gitea 请求连续 {retries} 次失败: {last}')
def get_json(self, path):
status, data = self.request('GET', path)
return data if status == 200 else None
def upload_asset(self, release_id, filepath, replace):
name = os.path.basename(filepath)
status, rel = self.request('GET', f'/releases/{release_id}')
if status != 200:
sys.exit(f'读取 release {release_id} 失败: {status} {rel}')
# Gitea 版本差异: assets 可能是 {items:[...]} 或纯数组
assets = rel.get('assets') or []
if isinstance(assets, dict):
assets = assets.get('items', [])
for asset in assets:
if asset['name'] == name:
if not replace:
print(f'资产 {name} 已存在, 跳过(覆盖请加 --replace)')
return
self.request('DELETE', f"/releases/{release_id}/assets/{asset['id']}")
print(f'已删除旧资产 {name}')
boundary = uuid.uuid4().hex
payload = open(filepath, 'rb').read()
body = (f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'
f'Content-Type: application/octet-stream\r\n\r\n').encode() + payload + \
f'\r\n--{boundary}--\r\n'.encode()
status, data = self.request(
'POST', f'/releases/{release_id}/assets?name={name}', body,
{'Content-Type': f'multipart/form-data; boundary={boundary}'})
if status != 201:
sys.exit(f'上传资产失败: {status} {data}')
print(f'已上传资产: {name} ({len(payload) / 1024 / 1024:.1f} MB)')
def main():
ap = argparse.ArgumentParser()
ap.add_argument('zipfile_path', help='发布 zip(release/ 下)')
ap.add_argument('--tag', help='release 标签, 默认取 zip 文件名去掉 -win64.zip')
ap.add_argument('--notes-file', help='release 说明 markdown 文件, 默认用内置模板')
ap.add_argument('--token', default=os.environ.get('GITEA_TOKEN'), help='API token')
ap.add_argument('--replace', action='store_true', help='覆盖已存在的同名资产')
ap.add_argument('--insecure', action='store_true', help='跳过 TLS 证书校验(自签证书)')
args = ap.parse_args()
if not os.path.exists(args.zipfile_path):
sys.exit(f'找不到 {args.zipfile_path}')
if not args.token:
sys.exit('缺少 API token: 传 --token 或设环境变量 GITEA_TOKEN')
tag = args.tag or re.sub(r'-win64\.zip$', '', os.path.basename(args.zipfile_path))
version = tag.removeprefix('I2C-Lab-v')
notes = open(args.notes_file, encoding='utf-8').read() if args.notes_file \
else DEFAULT_NOTES.format(version=version)
base, owner, repo = git_remote_info()
api = Api(base, owner, repo, args.token, args.insecure)
print(f'目标: {base}/{owner}/{repo} tag={tag}')
rel = api.get_json(f'/releases/tags/{tag}')
if rel:
print(f'release 已存在(id={rel["id"]}), 复用')
else:
status, rel = api.request('POST', '/releases', json.dumps({
'tag_name': tag,
'name': f'I2C-Lab v{version}',
'body': notes,
}).encode(), {'Content-Type': 'application/json'})
if status != 201:
sys.exit(f'创建 release 失败: {status} {rel}')
print(f'release 已创建(id={rel["id"]})')
api.upload_asset(rel['id'], args.zipfile_path, args.replace)
print(f'完成: {rel.get("html_url", "") or f"{base}/{owner}/{repo}/releases/tag/{tag}"}')
if __name__ == '__main__':
main()
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""i2cdec.py 协议自检(无硬件): 编码→解码往返 + T/W 行语法 + 鲁棒性
用法: python selftest.py (全部通过退出码 0)
覆盖:
1. 典型事务族(写/读/rep-START 链/quick/地址NAK)编码→解码 内容+时间戳往返
2. 100k/400k 两种速率下时间戳对齐
3. decode_edges 对随机边沿流不崩溃(畸形输入)
4. T 行 to/parse 往返 + 各 ended 标志
5. W 行解析与结束符
6. CSV 导出形状
"""
import random
import sys
import i2cdec as d
fails = []
def check(name, got, want):
ok = got == want
print(('PASS' if ok else 'FAIL'), name, '->', got, '' if ok else f'(期望 {want})')
if not ok:
fails.append(name)
def check_true(name, cond, detail=''):
ok = bool(cond)
print(('PASS' if ok else 'FAIL'), name, '->', detail)
if not ok:
fails.append(name)
T = d.Transaction
# 1) 典型事务族: 编码→解码 往返(内容键 + t0/t1 时间戳)
fam = [
T(t0_us=0, addr7=0x32, read=False, acks=(True, True, True),
data=bytes([0xFF, 0x60]), ended='stop'), # 切 bank 写
T(t0_us=0, addr7=0x32, read=True, acks=(True, False),
data=bytes([0x80]), ended='stop'), # 读回, 主机 NAK 结束
T(t0_us=0, addr7=0x50, read=False, acks=(True, True), data=b'\x00',
ended='rstart'), # 写寄存器号
T(t0_us=0, addr7=0x50, read=True, acks=(True, True, True),
data=b'\x12\x34', ended='stop'), # repeated-START 读
T(t0_us=0, addr7=0x10, read=False, acks=(False,), data=b'',
ended='stop'), # quick: 地址 NAK
T(t0_us=0, addr7=0x68, read=True, acks=(True,), data=b'',
ended='stop'), # quick 读
T(t0_us=0, addr7=0x2A, read=False, acks=(True,) + (True,) * 16,
data=bytes(range(16)), ended='stop'), # 长写 16 字节
]
for khz in (100.0, 400.0):
edges, refs = d.encode_edges(fam, khz=khz)
got = d.decode_edges(edges)
check(f'roundtrip count @{khz}kHz', len(got), len(fam))
for i, (g, w) in enumerate(zip(got, fam)):
check(f'tr[{i}] key @{khz}kHz', g.key(), w.key())
for i, (g, (t0, t1)) in enumerate(zip(got, refs)):
check(f'tr[{i}] t0 @{khz}kHz', g.t0_us, t0)
if t1 is not None:
check(f'tr[{i}] t1 @{khz}kHz', g.t1_us, t1)
# 2) 解码出的非 stop 事务 t1 定义: rstart 段 t1=下一事务 START 沿
edges, _refs = d.encode_edges(fam)
got = d.decode_edges(edges)
check('rstart ended', got[2].ended, 'rstart')
check_true('rstart t1 == next t0', got[2].t1_us == got[3].t0_us,
f'{got[2].t1_us} vs {got[3].t0_us}')
# 3) 流截断(cut)与半字节断(partial)
cut_fam = [
T(t0_us=0, addr7=0x32, read=False, acks=(True, True), data=b'\x6E',
ended='cut'), # 整字节后悬停
]
edges, _ = d.encode_edges(cut_fam)
got = d.decode_edges(edges)
check('cut ended', got[0].ended, 'cut')
check('cut key', got[0].key(), cut_fam[0].key())
# 手工构造半字节断: 在 cut 事务后只追加 2 个数据位(SCL 上升沿采样)
tail_t, _tail_v = edges[-1]
t = tail_t + 10
tail = [
(t, 1), # SDA 升(SCL 低)
(t + 1, 3), # SCL 升 -> 采样到 1
(t + 4, 1), # SCL 降
(t + 5, 3), # SCL 升 -> 采样到 1 (只到第 2 位)
]
got = d.decode_edges(edges + tail)
check('partial ended', got[0].ended, 'partial')
# 4) 随机边沿流不崩溃(内容不确定, 只要不抛异常且时间戳单调可行)
rng = random.Random(20260930)
for trial in range(50):
n = rng.randrange(1, 4000)
t = 0
rnd = []
for _ in range(n):
t += rng.randrange(1, 50)
rnd.append((t, rng.randrange(0, 4)))
try:
d.decode_edges(rnd)
except Exception as e: # noqa: BLE001
fails.append(f'random-trial-{trial}')
print('FAIL', f'random-trial-{trial}', '->', repr(e))
break
else:
print('PASS 随机边沿流 50 组无异常')
# 5) T 行 to/parse 往返(各 ended + 空/多字节)
tline_fam = [
T(t0_us=123456, t1_us=123543, addr7=0x32, read=False,
acks=(True, True, True), data=bytes([0xFF, 0x60]), ended='stop'),
T(t0_us=999, t1_us=1234, addr7=0x50, read=True,
acks=(True, False), data=bytes([0xAB]), ended='stop'),
T(t0_us=5, t1_us=99, addr7=0x2A, read=False, acks=(True, True),
data=b'\x01', ended='rstart'),
T(t0_us=7, t1_us=70, addr7=0x68, read=True, acks=(True,), data=b'',
ended='cut'),
T(t0_us=8, t1_us=80, addr7=0x08, read=False, acks=(False,), data=b'',
ended='partial'),
T(t0_us=9, t1_us=900, addr7=0x00, read=False, acks=(True,) * 17,
data=bytes(range(16)), ended='stop'),
]
for i, tr in enumerate(tline_fam):
line = d.to_tline(tr)
back = d.parse_tline(line)
check(f'tline[{i}] key', back.key(), tr.key())
check(f'tline[{i}] t0/dur', (back.t0_us, back.dur_us),
(tr.t0_us, tr.dur_us))
check(f'tline[{i}] truncated', back.truncated, tr.truncated)
# 截断标志 'L' 往返(固件长事务只保留前 56 字节)
trunc = T(t0_us=42, t1_us=99, addr7=0x32, read=False, acks=(True,) * 4,
data=bytes([0x11, 0x22, 0x33]), ended='stop', truncated=True)
check('tline trunc flags', d.to_tline(trunc),
'T 42 57 32 W -L AAAA 3 11 22 33')
check('tline trunc roundtrip', d.parse_tline(d.to_tline(trunc)).truncated, True)
try:
d.parse_tline('T 1 2 33 W rLL A 0')
fails.append('tline-flags-len-accepted')
print('FAIL tline-flags-len -> 被接受')
except ValueError:
print('PASS tline-flags-len -> 拒绝')
check('tline sample', d.to_tline(tline_fam[0]),
'T 123456 87 32 W - AAA 2 FF 60')
check('tline addr-only', d.to_tline(tline_fam[4]), 'T 8 72 08 W p N 0')
# 非法 T 行被拒
for bad in ('X 1 2 3 W - A 0', 'T 1 2 3 W - A 2 11',
'T 1 2 3 Q - A 0', 'T 1 2 3 W z A 0', 'T 1 2 3 W - AX 0',
'T 1 2 3 W - A -1'):
try:
d.parse_tline(bad)
fails.append(f'tline-bad-accepted:{bad!r}')
print('FAIL', f'tline-bad:{bad!r}', '-> 被接受')
except ValueError:
print('PASS', f'tline-bad:{bad!r}', '-> 拒绝')
# 6) W 行解析
check('wline', d.parse_wline('W 1234 3'), (1234, 3))
check('wline end', d.parse_wline('W.'), None)
try:
d.parse_wline('W 12 7')
fails.append('wline-bad-accepted')
print('FAIL wline-bad -> 被接受')
except ValueError:
print('PASS wline-bad -> 拒绝')
# 7) CSV 导出形状
csv = d.transactions_to_csv(tline_fam).splitlines()
check('csv header', csv[0],
'idx,t0_us,dur_us,addr7,rw,addr_ack,data_hex,data_acks,ended')
check('csv rows', len(csv), 1 + len(tline_fam))
check('csv row0', csv[1], '0,123456,87,32,W,A,FF 60,AA,stop')
# 8) 显示行格式(format 输出含地址/方向/数据/ACK)
edges, _ = d.encode_edges(fam)
tr = d.decode_edges(edges)[0]
line = tr.format(idx=0)
check_true('format 含地址与方向',
' 0x32 W: FF 60' in line and 'ACK:AAA' in line, line)
check_true('format 含时间戳', 't=' in line and 'us' in line, line)
print()
if fails:
print(f'共 {len(fails)} 项失败:', ', '.join(fails))
sys.exit(1)
print('全部通过')
+151
View File
@@ -0,0 +1,151 @@
"""GUI 功能自检(offscreen 无窗口): 事务入表/地址过滤/暂停滚动/波形喂数/
CSV 导出池/固件统计行/配置往返
====================================================================
不依赖硬件与串口, 直接驱动 Window 的事件处理函数; 改 main.py 后建议重跑。
用法: python selftest_gui.py (全部通过退出码 0)
"""
import os
import sys
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from PySide6.QtWidgets import QApplication
import i2cdec
import main as m
from i2cdec import Transaction
fails = []
def check(name, got, want):
ok = got == want
print(('PASS' if ok else 'FAIL'), name, '->', repr(got)[:120],
'' if ok else f'(期望 {want!r})')
if not ok:
fails.append(name)
app = QApplication(sys.argv)
win = m.Window()
win.show()
T = Transaction
# 1) 事务入表 + 全量池
tr1 = T(t0_us=100, t1_us=200, addr7=0x32, read=False,
acks=(True, True), data=b'\xff\x60', ended='stop')
tr2 = T(t0_us=900, t1_us=1100, addr7=0x50, read=True,
acks=(True, False), data=b'\x12', ended='stop')
win._paused = False
for tr in (tr1, tr2):
win._on_trans(tr)
check('表格行数', win.table.rowCount(), 2)
check('导出池数量', len(win.transactions), 2)
check('事务计数', win._trans_total, 2)
check('地址列', win.table.item(0, 3).text(), '0x32')
check('RW 列', win.table.item(1, 4).text(), 'R')
check('ACK 列', win.table.item(0, 5).text(), 'AA')
check('数据列', win.table.item(0, 6).text(), 'FF 60')
check('收尾列', win.table.item(0, 7).text(), 'STOP')
# 2) 地址过滤: 只留 0x32; 非法过滤项被忽略
win.ed_filter.setText('32 zz')
win._on_trans(tr2)
check('过滤生效(0x50 不入表)', win.table.rowCount(), 2)
check('计数仍累计', win._trans_total, 3)
win.ed_filter.setText('')
win._on_trans(tr2)
check('清过滤后入表', win.table.rowCount(), 3)
# 3) 地址 NAK 标红 / 未收尾标橙(前景色判断)
tr_nak = T(t0_us=1, t1_us=2, addr7=0x10, read=False, acks=(False,),
data=b'', ended='stop')
tr_cut = T(t0_us=3, t1_us=9, addr7=0x11, read=False, acks=(True,),
data=b'', ended='cut')
win._on_trans(tr_nak)
win._on_trans(tr_cut)
r_nak = win.table.rowCount() - 2
check('地址 NAK 前景红色',
win.table.item(r_nak, 5).foreground().color().name(), '#e0564c')
check('cut 收尾前景橙色',
win.table.item(r_nak + 1, 7).foreground().color().name(), '#e08a2e')
# 4) 长事务截断显示 + tooltip 全量
tr_long = T(t0_us=1, t1_us=2, addr7=0x2a, read=False, acks=(True,) * 20,
data=bytes(range(20)), ended='stop')
win._on_trans(tr_long)
r = win.table.rowCount() - 1
check('长数据显示截断省略', win.table.item(r, 6).text().endswith('…'), True)
check('长数据 tooltip 全量',
win.table.item(r, 6).toolTip() == tr_long.data_hex(), True)
# 5) 暂停滚动: 不再 scrollToBottom, 但行照加
win._toggle_pause()
check('暂停按钮文案', win.btn_pause.text(), '继续滚动')
win._on_trans(tr1)
check('暂停时行照加', win.table.rowCount() >= 6, True)
win._toggle_pause()
# 6) 表格行数上限: 批量灌 5100 条, 顶行被裁
win._clear_table()
for i in range(5100):
win._on_trans(T(t0_us=i, t1_us=i + 1, addr7=0x01, read=False,
acks=(True,), data=b'', ended='stop'))
check('表格行数封顶', win.table.rowCount(), m.TABLE_CAP)
check('导出池保留全部(<STORE_CAP)', len(win.transactions), win._trans_total)
check('导出池未达上限', len(win.transactions) <= m.STORE_CAP, True)
# 7) 波形喂数 + 注记解码
fam = [T(addr7=0x32, read=False, acks=(True, True, True),
data=bytes((0xFF, 0x60)), ended='stop')]
edges, _refs = i2cdec.encode_edges(fam, khz=100.0, t0_us=1000)
win._on_wave(edges, 0)
check('波形有数据', win.wave.has_data(), True)
check('波形注记解码 1 事务', len(win.wave._trans), 1)
check('波形快照标签', win.lb_wave.text(),
f'{len(edges)} 边沿, 解码 1 事务')
# 8) 固件 stats 行解析
win._on_info('= stats edges=1234 trans=56 trans_drop=0 susp=0 '
'ring_ovf=0 last_edge_ago_us=12 mon=1')
check('固件计数显示', win.lb_fw.text().startswith('固件计数: edges=1234'), True)
# 9) 配置往返
win.ed_filter.setText('32 50')
win.spin_wave_n.setValue(4096)
cfg = win._store_from_ui()
check('配置: 过滤', cfg['mon']['addr_filter'], '32 50')
check('配置: 波形边沿数', cfg['mon']['wave_n'], 4096)
config_path = m.config.CONFIG_FILE
try:
os.remove(config_path)
except OSError:
pass
m.config.save(cfg)
check('配置落盘读回', m.config.load()['mon']['wave_n'], 4096)
with open(config_path, 'w', encoding='utf-8') as f: # 损坏文件容错
f.write('{not json')
check('配置容错(损坏回落)', m.config.load()['mon']['addr_filter'], '')
check('配置容错(缺键补齐)', m.config.load()['mon']['wave_n'],
m.config.DEFAULTS['mon']['wave_n'])
# 10) 演示模式一键开停(不开定时器轮询, 仅验证状态翻转)
win._toggle_demo()
check('演示启动', win._demo_timer.isActive(), True)
win._demo_tick()
win._drain_events()
check('演示 tick 产生事务', win._trans_total > 0, True)
win._toggle_demo()
check('演示停止', not win._demo_timer.isActive(), True)
win.close()
app.quit()
print()
if fails:
print(f'共 {len(fails)} 项失败:', ', '.join(fails))
sys.exit(1)
print('全部通过')
+132
View File
@@ -0,0 +1,132 @@
"""Theme persistence and Fluent Widgets integration.
与 DLPilot/SerialMate 的 theme.py 同源(三件套: setTheme + QPalette/QSS
+ DWM 标题栏), 仅改应用名。"""
import ctypes
import os
from PySide6.QtCore import QObject, QSettings, Qt
from PySide6.QtGui import QColor, QPalette
from qfluentwidgets import Theme, setTheme, setThemeColor
# Fluent widgets restyle themselves on setTheme(); QMainWindow, the native
# QTabWidget/QStatusBar and the Windows title bar keep the OS palette unless
# the palette, an app stylesheet and the DWM title bar attributes follow suit
# (same approach as DLPilot app_fluent.py).
_SURFACES = {
False: {"window": "#f4f6f8", "base": "#ffffff", "text": "#1f2328",
"dim": "#57606a", "border": "#d8dee4", "selected_text": "#ffffff"},
True: {"window": "#202020", "base": "#2b2b2b", "text": "#f5f5f5",
"dim": "#9a9a9a", "border": "#3a3a3a", "selected_text": "#1f2328"},
}
class ThemeManager(QObject):
"""Keep the appearance choice persistent and synchronized with Qt."""
MODES = {"system", "light", "dark", "dark1", "dark2"}
_THEMES = {
"system": Theme.AUTO,
"light": Theme.LIGHT,
"dark": Theme.DARK,
"dark1": Theme.DARK,
"dark2": Theme.DARK,
}
_ACCENTS = {
"light": "#0f6cbd",
"dark": "#f2c94c",
"dark1": "#5aa9e6",
"dark2": "#35c6c8",
}
def __init__(self, app):
super().__init__(app)
self.app = app
self.window = None
self.settings = QSettings("LT8619C-Tools", "I2C-Lab")
mode = self.settings.value("appearance/theme", "system")
self.mode = mode if mode in self.MODES else "system"
self.apply()
app.styleHints().colorSchemeChanged.connect(self.apply)
def set_window(self, window):
"""Register the main window so the native title bar follows the theme."""
self.window = window
self._apply_titlebar()
def set_mode(self, mode):
if mode not in self.MODES:
raise ValueError(f"Unknown theme: {mode}")
self.mode = mode
self.settings.setValue("appearance/theme", mode)
self.apply()
def is_dark(self):
if self.mode in {"dark", "dark1", "dark2"}:
return True
return self.mode == "system" and self.app.styleHints().colorScheme() == Qt.ColorScheme.Dark
def apply(self, *_):
is_dark = self.is_dark()
setTheme(self._THEMES[self.mode])
setThemeColor(self._ACCENTS.get(self.mode, self._ACCENTS["light"]))
self._apply_palette(is_dark)
self._apply_titlebar()
def _apply_palette(self, is_dark):
"""Give native Qt surfaces the same colors as the Fluent widgets."""
surface = _SURFACES[is_dark]
accent = QColor(self._ACCENTS.get(self.mode, self._ACCENTS["light"]))
palette = QPalette()
for role, color in (
(QPalette.Window, surface["window"]),
(QPalette.Base, surface["base"]),
(QPalette.AlternateBase, surface["base"]),
(QPalette.Text, surface["text"]),
(QPalette.WindowText, surface["text"]),
(QPalette.Button, surface["base"]),
(QPalette.ButtonText, surface["text"]),
(QPalette.PlaceholderText, surface["dim"]),
(QPalette.Highlight, accent),
(QPalette.HighlightedText, surface["selected_text"]),
(QPalette.ToolTipBase, surface["base"]),
(QPalette.ToolTipText, surface["text"]),
):
palette.setColor(role, QColor(color))
self.app.setPalette(palette)
self.app.setStyleSheet(f"""
QMainWindow, QWidget#root {{ background-color: {surface['window']}; color: {surface['text']}; }}
QTabWidget::pane {{ background: {surface['base']}; border: 1px solid {surface['border']}; border-radius: 6px; }}
QTabBar {{ qproperty-drawBase: 0; }}
QTabBar::tab {{ background: transparent; color: {surface['dim']}; padding: 6px 16px;
border: 1px solid transparent; border-top-left-radius: 6px;
border-top-right-radius: 6px; }}
QTabBar::tab:selected {{ background: {surface['base']}; color: {surface['text']};
border: 1px solid {surface['border']}; }}
QTabBar::tab:hover:!selected {{ background: {surface['base']}; }}
QSplitter::handle {{ background: {surface['window']}; }}
QStatusBar {{ background: {surface['window']}; color: {surface['dim']};
border-top: 1px solid {surface['border']}; }}
QToolTip {{ background-color: {surface['base']}; color: {surface['text']};
border: 1px solid {surface['border']}; }}
""")
def _apply_titlebar(self):
"""Ask DWM for a title bar in the current surface color (Win10/11)."""
if self.window is None or os.name != "nt" or not hasattr(ctypes, "windll"):
return
try:
hwnd = int(self.window.winId())
dwm = ctypes.windll.dwmapi
dark = ctypes.c_int(1 if self.is_dark() else 0)
dwm.DwmSetWindowAttribute(hwnd, 20, ctypes.byref(dark), ctypes.sizeof(dark))
surface = _SURFACES[self.is_dark()]
dwm.DwmSetWindowAttribute(hwnd, 35, ctypes.byref(self._colorref(QColor(surface["window"]))), 4)
dwm.DwmSetWindowAttribute(hwnd, 36, ctypes.byref(self._colorref(QColor(surface["text"]))), 4)
except Exception:
pass
@staticmethod
def _colorref(color):
return ctypes.c_int(color.red() | (color.green() << 8) | (color.blue() << 16))
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
waveview.py — I2C 波形视图(逻辑分析仪风格, 纯 QPainter 绘制)
==============================================================
输入: 边沿快照 [(t_us, pins)..] (bit0=SDA bit1=SCL), 自动叠加
i2cdec.decode_edges 的事务注记(START/STOP 刻线 + 事务区间标签)。
交互: 滚轮缩放(以光标为锚), 左键拖动平移, 双击适配全宽,
Ctrl+滚轮 8 倍步进; 空数据时画占位提示。
主题: 取当前 QPalette 判明暗, 线色固定(SCL 青/SDA 橙, 双主题可辨)。
"""
from PySide6.QtCore import QPoint, Qt
from PySide6.QtGui import QColor, QFont, QMouseEvent, QPainter, QPalette, QPen
from PySide6.QtWidgets import QWidget
import i2cdec
COL_SCL = QColor('#39b8e0')
COL_SDA = QColor('#f2952e')
COL_MARK_START = QColor('#4caf50')
COL_MARK_STOP = QColor('#e0564c')
TOP_PAD = 18 # 顶部标签带
LANE_H = 46 # 每条线高度
LANE_GAP = 18
RULER_H = 22 # 底部时间刻度
MIN_US_PER_PX = 0.001
MAX_US_PER_PX = 5000.0
class WaveView(QWidget):
"""SDA/SCL 双线时序图 + 事务注记; set_edges() 喂数据"""
def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumHeight(TOP_PAD + 2 * LANE_H + LANE_GAP + RULER_H + 8)
self.setMouseTracking(True)
self._edges = [] # [(t_us, pins)]
self._trans = [] # 注记事务
self._us_per_px = 5.0
self._t_left = 0.0 # 视窗左端时间(us)
self._hover_t = None
self._drag = None
self._font = QFont(self.font().family(), 8)
# ---------- 数据 ----------
def set_edges(self, edges):
self._edges = list(edges)
try:
self._trans = i2cdec.decode_edges(self._edges)
except Exception:
self._trans = []
self.fit_all()
self.update()
def has_data(self):
return len(self._edges) >= 2
# ---------- 视窗 ----------
def t_span(self):
if not self._edges:
return 0.0
return self._edges[-1][0] - self._edges[0][0]
def fit_all(self):
w = max(self.width() - 2, 1)
span = self.t_span()
self._us_per_px = max(min(span / w, MAX_US_PER_PX), MIN_US_PER_PX)
self._t_left = self._edges[0][0] if self._edges else 0.0
def _x(self, t):
return (t - self._t_left) / self._us_per_px
def _t(self, x):
return self._t_left + x * self._us_per_px
def _time_base(self):
return self._edges[0][0] if self._edges else 0
# ---------- 事件 ----------
def mousePressEvent(self, ev: QMouseEvent):
if ev.button() == Qt.LeftButton:
self._drag = (ev.position().x(), self._t_left)
def mouseMoveEvent(self, ev: QMouseEvent):
x = ev.position().x()
if self._drag is not None:
self._t_left = self._drag[1] - (x - self._drag[0]) * self._us_per_px
self.update()
self._hover_t = self._t(x)
self.update()
def mouseReleaseEvent(self, ev):
if ev.button() == Qt.LeftButton:
self._drag = None
def leaveEvent(self, ev):
self._hover_t = None
self.update()
def wheelEvent(self, ev):
factor = 1.25 if ev.angleDelta().y() > 0 else 0.8
if ev.modifiers() & Qt.ControlModifier:
factor = factor ** 3
anchor_t = self._hover_t if self._hover_t is not None \
else self._t(self.width() / 2)
self._us_per_px = max(min(self._us_per_px / factor, MAX_US_PER_PX),
MIN_US_PER_PX)
self._t_left = anchor_t - (anchor_t - self._t_left)
# 以光标为锚: 保持 anchor 在原像素位置
if self._hover_t is not None:
self._t_left = anchor_t - ev.position().x() * self._us_per_px
self.update()
def mouseDoubleClickEvent(self, ev):
self.fit_all()
self.update()
def resizeEvent(self, ev):
super().resizeEvent(ev)
# ---------- 绘制 ----------
def paintEvent(self, _ev):
p = QPainter(self)
try:
self._paint(p)
finally:
p.end() # 异常路径也必须收笔, 否则画布状态损坏
def _paint(self, p):
p.setFont(self._font)
w, h = self.width(), self.height()
pal = self.palette()
base = pal.color(QPalette.ColorRole.Base)
text = pal.color(QPalette.ColorRole.Text)
dim = QColor(text)
dim.setAlpha(150)
p.fillRect(self.rect(), base)
lane_y = {'SCL': TOP_PAD, 'SDA': TOP_PAD + LANE_H + LANE_GAP}
for name, color in (('SCL', COL_SCL), ('SDA', COL_SDA)):
y = lane_y[name]
p.setPen(QPen(dim, 1, Qt.DashLine))
p.drawLine(0, y + LANE_H, w, y + LANE_H) # 低电平基线
p.setPen(QPen(color))
p.drawText(4, y + 12, name)
if not self.has_data():
p.setPen(QPen(dim))
p.drawText(self.rect(), Qt.AlignCenter,
'无边沿数据 — 连接固件后点「抓取波形」')
return
t0_view, t1_view = self._t(0), self._t(w)
edges = self._edges
# 视窗外裁剪: 二分找起点
import bisect
ts = [e[0] for e in edges]
i0 = max(bisect.bisect_left(ts, t0_view) - 1, 0)
# 电平阶梯线(两线一起走)
pens = {'SCL': QPen(COL_SCL, 2), 'SDA': QPen(COL_SDA, 2)}
last = {'SCL': None, 'SDA': None}
for i in range(i0, len(edges)):
t, pins = edges[i]
if t > t1_view:
break
scl, sda = (pins >> 1) & 1, pins & 1
x = self._x(t)
for name, lv in (('SCL', scl), ('SDA', sda)):
y_lo = lane_y[name] + LANE_H
y_hi = lane_y[name] + 4
y = y_lo if lv == 0 else y_hi
if last[name] is not None:
py, px = last[name]
p.setPen(pens[name])
if py != y: # 前一电平延伸到跳变点
p.drawLine(QPoint(int(px), int(py)), QPoint(int(x), int(py)))
p.drawLine(QPoint(int(x), int(py)), QPoint(int(x), int(y)))
last[name] = (y, x)
for name in ('SCL', 'SDA'): # 末段延伸到右缘
if last[name] is not None:
py, px = last[name]
p.setPen(pens[name])
p.drawLine(QPoint(int(px), int(py)), QPoint(w, int(py)))
# 事务注记: START/STOP 刻线 + 事务标签(顶部)
p.setPen(QPen(dim, 1))
for tr in self._trans:
xs = self._x(tr.t0_us)
xe = self._x(tr.t1_us)
if xe < 0 or xs > w:
continue
p.setPen(QPen(COL_MARK_START, 1, Qt.DashLine))
p.drawLine(QPoint(int(xs), TOP_PAD - 4), QPoint(int(xs), h - RULER_H))
p.setPen(QPen(COL_MARK_STOP, 1, Qt.DashLine))
p.drawLine(QPoint(int(xe), TOP_PAD - 4), QPoint(int(xe), h - RULER_H))
label = f'{tr.addr7:02X}{tr.rw_char()} {tr.data_hex(6)}'
p.setPen(QPen(text))
if xe - xs > p.fontMetrics().horizontalAdvance(label) + 8:
p.drawText(int(xs) + 3, TOP_PAD - 6, label)
elif 0 <= xs <= w - 30:
p.drawText(int(xs) + 3, TOP_PAD - 6, f'{tr.addr7:02X}')
# 时间刻度(底部): 取"好看"的步长
p.setPen(QPen(dim, 1))
p.drawLine(0, h - RULER_H + 4, w, h - RULER_H + 4)
nice = self._nice_step(self._us_per_px * 90)
t = (int(t0_view // nice) + 1) * nice
base_t = self._time_base()
while t <= t1_view:
x = int(self._x(t))
p.drawLine(x, h - RULER_H + 4, x, h - RULER_H + 10)
p.drawText(x + 2, h - RULER_H + 18, self._fmt_us(t - base_t))
t += nice
# 光标
if self._hover_t is not None and 0 <= self._hover_t <= t1_view:
x = int(self._x(self._hover_t))
p.setPen(QPen(text.darker(120), 1, Qt.DotLine))
p.drawLine(x, TOP_PAD - 6, x, h - RULER_H + 4)
p.setPen(QPen(text))
p.drawText(min(x + 4, w - 70), TOP_PAD + 10,
self._fmt_us(self._hover_t - base_t))
# ---------- 工具 ----------
@staticmethod
def _nice_step(raw):
steps = (1, 2, 5)
mag = 1.0
while mag < raw:
for s in steps:
if s * mag >= raw:
return s * mag
mag *= 10
return mag
@staticmethod
def _fmt_us(v):
v = abs(v)
if v >= 1e6:
return f'{v / 1e6:.3f}s'
if v >= 1000:
return f'{v / 1000:.2f}ms'
return f'{v:.0f}us'
+32 -12
View File
@@ -11,7 +11,9 @@
| <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) | | <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) |
| <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) | | <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) |
| <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) | | <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) |
| <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="I2C-Lab/assets/i2c-lab.png" width="64" alt="I2C-Lab 图标"/> | [I2C-Lab](I2C-Lab/) | I2C 总线监控调试台:RP2040 probe 固件纯被动监听(PIO 硬件捕获 + 实时解码,不加上拉不驱动),PC 端实时事务流(地址/RW/ACK/数据/收尾)、地址过滤、CSV 导出、逻辑分析仪式波形快照(滚轮缩放/事务注记);主机读写与从机模拟在路线图上(前身 I2C-Inject 的注入职能将并入) | 到 [Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases) 下 `I2C-Lab-vX.Y.Z-win64.zip`(含 probe 固件 UF2),解压即用;详见 [I2C-Lab/README.md](I2C-Lab/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-Lab(监控已就绪,注入将在其主机阶段复活) | 暂不提供发布;状态与原理详见 [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) |
## 环境要求 ## 环境要求
@@ -20,9 +22,11 @@
`py -m pip install PySide6-Fluent-Widgets`(自定义图片 JPG/BMP 可选 Pillow、光机控制可选 pyserial); `py -m pip install PySide6-Fluent-Widgets`(自定义图片 JPG/BMP 可选 Pillow、光机控制可选 pyserial);
EDID-viewer GUI 需 `py -m pip install -r EDID-viewer/requirements.txt`(PySide6 + PySide6-Fluent-Widgets); EDID-viewer GUI 需 `py -m pip install -r EDID-viewer/requirements.txt`(PySide6 + PySide6-Fluent-Widgets);
SerialMate GUI 需 `py -m pip install PySide6 PySide6-Fluent-Widgets pyserial`; SerialMate GUI 需 `py -m pip install PySide6 PySide6-Fluent-Widgets pyserial`;
I2C-Inject 需 `py -m pip install pyserial` I2C-Lab GUI 同上;重新构建 probe 固件需 pico-sdk 2.x + Arm GNU 交叉工具链
(见 I2C-Lab/AGENTS.md)
- 重新打包:`DLPilot/src/build_exe.py`、`EDID-viewer/src/build_exe.py` 与 - 重新打包:`DLPilot/src/build_exe.py`、`EDID-viewer/src/build_exe.py` 与
`SerialMate/src/build_exe.py`(均自动安装 PyInstaller);I2C-Inject 目录内 `python build_exe.py` `SerialMate/src/build_exe.py`、`I2C-Lab/src/build_exe.py`(均自动安装 PyInstaller);
I2C-Inject 目录内 `python build_exe.py`
## 目录结构 ## 目录结构
@@ -35,39 +39,55 @@ HW-Debug-Tools/
├── DLPilot/ ├── DLPilot/
│ ├── src/ # 源码 + 打包/发布脚本 + 图标源 │ ├── src/ # 源码 + 打包/发布脚本 + 图标源
│ ├── assets/ # 生成的测试图案 PNG(gen_*.py 产物) │ ├── assets/ # 生成的测试图案 PNG(gen_*.py 产物)
│ ├── release/ # 打包 exe + 运行配置(入库);发布 zip(不入库) │ ├── release/ # 交付物(exe/zip 不入库,本地保留;只上传 Gitea Release 附件)
│ ├── README.md # 使用说明 │ ├── README.md # 使用说明
│ └── AGENTS.md # 工程档案 │ └── AGENTS.md # 工程档案
├── EDID-viewer/ ├── EDID-viewer/
│ ├── src/ # 界面/解析/WMI 读取 + 打包/发布脚本 │ ├── src/ # 界面/解析/WMI 读取 + 打包/发布脚本
│ ├── assets/ # 图标与生成脚本 │ ├── assets/ # 图标与生成脚本
│ ├── release/ # EDID-Viewer.exe + README(入库);发布 zip(不入库) │ ├── release/ # 交付物(exe/zip 不入库,本地保留;只上传 Gitea Release 附件)
│ ├── README.md # 使用说明 │ ├── README.md # 使用说明
│ └── AGENTS.md # 工程档案 │ └── AGENTS.md # 工程档案
├── SerialMate/ ├── SerialMate/
│ ├── src/ # 协议/串口线程/曲线控件/GUI + 自检 + 打包/发布脚本 │ ├── src/ # 协议/串口线程/曲线控件/GUI + 自检 + 打包/发布脚本
│ ├── release/ # SerialMate.exe + README(入库);发布 zip(不入库) │ ├── release/ # 交付物(exe/zip 不入库,本地保留;只上传 Gitea Release 附件)
│ ├── README.md # 使用说明 │ ├── README.md # 使用说明
│ └── AGENTS.md # 工程档案 │ └── AGENTS.md # 工程档案
├── I2C-Inject/ # PC 端 GUI + RP2040 固件 + 打包 exe + 图标 + README ├── I2C-Lab/
│ ├── src/ # 解码/固件链路/波形控件/GUI + 自检 + 打包/发布脚本
│ ├── firmware/ # probe 固件(pico-sdk C + PIO + make_uf2.py)
│ ├── assets/ # 图标(I2C 橙色母题)
│ ├── release/ # I2C-Lab.exe + firmware/i2c_lab.uf2(交付物本地保留,不入库;发布时走流水线)
│ ├── README.md # 使用说明
│ └── AGENTS.md # 工程档案
├── I2C-Inject/ # ⏸ 暂停开发留档(PC GUI + MicroPython 固件 + 打包 exe + 图标 + README)
├── gd32e230f8_firmware_merge_tool/ # 🔗 git 子模块 → GD32E230 固件工具(独立仓库,见下方备注)
├── icons/ # 图标家族唯一权威:风格与内容约定(README.md)+ 统一生成器(make_icon.py)
├── LICENSE ├── LICENSE
└── README.md └── README.md
``` ```
## 发布流程(DLPilot / EDID-Viewer / SerialMate 同一套流水线) ## 发布流程(DLPilot / EDID-Viewer / SerialMate / I2C-Lab 同一套流水线)
```powershell ```powershell
cd DLPilot/src # 或 EDID-viewer/src、SerialMate/src cd DLPilot/src # 或 EDID-viewer/src、SerialMate/src、I2C-Lab/src
python build_exe.py # 打包 → ../release/<工具>.exe python build_exe.py # 打包 → ../release/<工具>.exe
python package_release.py <版本> # → ../release/<工具>-v<版本>-win64.zip python package_release.py <版本> # → ../release/<工具>-v<版本>-win64.zip
python publish_gitea.py <工具>-v<版本>-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN) python publish_gitea.py <工具>-v<版本>-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN)
``` ```
每个工具独立打标签发布(`DLPilot-v*` / `EDID-Viewer-v*` / `SerialMate-v*`),Release 列表按时间倒序混排于同一页面。 每个工具独立打标签发布(`DLPilot-v*` / `EDID-Viewer-v*` / `SerialMate-v*` /
`I2C-Lab-v*`),Release 列表按时间倒序混排于同一页面。
## 备注 ## 备注
- 源码与打包产物(exe、pattern_slots.json)均入库;发布 zip 不入库,只上传 Gitea Release - 源码与运行配置入库;exe/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-28 - 全部工具图标(logo 家族:黑底 + 工具色圆角块 + 两行字)的约定与再生成见
[icons/README.md](icons/README.md),统一生成器 `icons/make_icon.py`
- 更新:2026-10-10
+110 -5
View File
@@ -18,7 +18,7 @@ SerialMate 是其中的**通用串口/RS485 调试助手**:收发 + 协议解
``` ```
SerialMate/ SerialMate/
├── src/ 源代码区 —— 只有这里可以手改 ├── src/ 源代码区 —— 只有这里可以手改
├── release/ 产物区 —— SerialMate.exe + README.md(入库), ├── release/ 产物区 —— SerialMate.exe + README.md(exe 不入库本地保留,README 入库),
│ *-win64.zip(gitignore,只上传 Release) │ *-win64.zip(gitignore,只上传 Release)
├── assets/ 图标等生成资产(暂无;build_exe.py 已预留 assets/serial-mate.ico) ├── assets/ 图标等生成资产(暂无;build_exe.py 已预留 assets/serial-mate.ico)
├── README.md 人类文档 ├── README.md 人类文档
@@ -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`
@@ -78,6 +93,14 @@ SerialMate/
5. **解析重同步策略**:前缀令牌逐位对齐(HDR/字面量须匹配、ID 通配),LEN 值经 5. **解析重同步策略**:前缀令牌逐位对齐(HDR/字面量须匹配、ID 通配),LEN 值经
口径换算为 payload 长度,负值视为伪帧头逐出;CRC 失配丢 1 字节重同步—— 口径换算为 payload 长度,负值视为伪帧头逐出;CRC 失配丢 1 字节重同步——
通用工具不预设合法 LEN 区间(与 DLPilot 的 LEN 范围预判不同)。 通用工具不预设合法 LEN 区间(与 DLPilot 的 LEN 范围预判不同)。
5a. **解析不吞数据(v0.1.2 起的产品语义)**:串口助手是本体、协议解析是附加层。
FrameParser 重同步丢弃的字节累积进 `garbage`,`link.SerialLink._dispatch_rx`
每批 `drain_garbage()` 无条件取走(未接回调也不许在解析器里无限累积),
经 `on_garbage(data, ts)` 上抛;GUI 把它并入 `('raw', ...)` 事件,与透传行
同格式显示。纯文本设备/波特率不符的乱码在解析模式下必须全可见。
例外:只匹配上前缀、还在等后续字节的"半帧"缓冲不算垃圾(可能补全成真帧),
字节流恰好在帧头后截断时会扣留至多一个前缀长度的字节——可接受,勿改成
超时强制吐出(会把慢速真帧撕碎)。改此语义需同步 selftest 8b / loop 3b。
6. **ID 位不校验只透出**:`Frame.code` 原样上抛(布局无 ID 时为 None,GUI 日志 6. **ID 位不校验只透出**:`Frame.code` 原样上抛(布局无 ID 时为 None,GUI 日志
与 selftest 均需兼容 None),TYPE_NAMES 仅用于显示译码(DLPilot 语义)。 与 selftest 均需兼容 None),TYPE_NAMES 仅用于显示译码(DLPilot 语义)。
7. **BASE_DIR 语义**(config.py):frozen 时 =exe 所在目录,源码运行时 =src/; 7. **BASE_DIR 语义**(config.py):frozen 时 =exe 所在目录,源码运行时 =src/;
@@ -135,9 +158,9 @@ SerialMate/
```powershell ```powershell
python -m compileall -q . # 全模块可编译 python -m compileall -q . # 全模块可编译
python selftest.py # 协议回归(47 项, 改 protocol.py 后必跑) python selftest.py # 协议回归(67 项, 改 protocol.py 后必跑)
python selftest_loop.py # 链路回环(需 pyserial, 改 link.py 后必跑) python selftest_loop.py # 链路回环(21 项, 需 pyserial, 改 link.py 后必跑)
python selftest_gui.py # GUI 功能回归(offscreen, 改 main.py 后建议重跑) python selftest_gui.py # GUI 功能回归(68 项, offscreen, 改 main.py 后建议重跑)
python main.py --seconds 5 # GUI 冒烟(源码); offscreen: set QT_QPA_PLATFORM=offscreen python main.py --seconds 5 # GUI 冒烟(源码); offscreen: set QT_QPA_PLATFORM=offscreen
python build_exe.py # → ../release/SerialMate.exe(缺 PyInstaller 自动装) python build_exe.py # → ../release/SerialMate.exe(缺 PyInstaller 自动装)
python package_release.py <版本> # → ../release/SerialMate-v<版本>-win64.zip python package_release.py <版本> # → ../release/SerialMate-v<版本>-win64.zip
@@ -181,6 +204,88 @@ tag/release 命名 `SerialMate-vX.Y.Z`,zip 命名
## 7. 进度记录 ## 7. 进度记录
### v0.1.2(2026-10-16,接收语义修复:解析模式不再吞数据)
背景:用户接 COM25(非 DLPilot 协议设备)开解析模式,接收区一片空白——
`_dispatch_rx` 在解析模式只上抛完整合法帧,不成帧的字节被 FrameParser 静默
丢弃;用户明确产品定位:**首先是基础串口调试工具,自定义协议解析是附加项**,
附加能力不得影响本体功能。
已完成:
- protocol.py:FrameParser 丢弃字节不再销毁——重同步逐出的字节累积进
`garbage`,新增 `drain_garbage()` 取走清空;`clear()` 一并清垃圾;
`feed()` 返回值不变(旧用法零改动)。
- link.py:新增 `on_garbage(data, ts)` 回调(区别于透传模式的 `on_raw`,
语义可分但 GUI 同样并入 raw 事件显示);`_dispatch_rx` 每批 drain 无条件
执行(未接回调也不累积),有字节且接了回调才上抛,ts=同一批到达时刻。
- main.py:接线 `on_garbage → ('raw', ...)`;"解析协议帧(收)"补 tooltip
说明两模式语义;APP_VERSION 0.1.2。
- 例外语义(有意为之):只匹配前缀、等待后续字节的半帧缓冲不算垃圾
(可能补全成真帧);字节流恰在帧头后截断时扣留至多一个前缀长度。
- 自检扩充:selftest 8b(纯文本全透出/噪声+帧+尾字节/坏CRC帧字节全透出/
半帧头扣留后补全成帧)+10 项;selftest_loop 3b(解析模式非帧字节经
on_garbage 透出、回环 TX 帧只算非帧字节)+3 项;selftest_gui 5b
(on_garbage 接线+非帧字节日志行)+2 项;全部通过。
- README 协议解析段同步"解析不吞数据"语义;exe 重建(v0.1.2);
已发布:tag `SerialMate-v0.1.2` 指向 `8e91e66`,Release 附件
`SerialMate-v0.1.2-win64.zip`(63.6 MB)已上传核对。发布当日本机(联想
笔记本)到 1234 端口 TCP 能连但 banner 静默(22/443 正常,异机 N100
SSH 认证正常——服务端无恙,是本机网络路径对 1234 的选择性干扰),
push 改走 HTTPS+token 成功;TLS 抽风加 `--insecure` 老毛病不变。
待办 / 计划:无新增,沿用 v0.1.0 待办。
### 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 卡顿成批挤压、显示延迟随运行时间增大;
修复后时间戳单调且间隔≈设定周期,长时间运行无卡顿增长。
+7 -2
View File
@@ -9,8 +9,10 @@
- **串口收发**:端口自动枚举、波特率 1200~921600、数据位/校验/停止位可选, - **串口收发**:端口自动枚举、波特率 1200~921600、数据位/校验/停止位可选,
参数热切换;半双工 RS485 友好(每周期最多写一帧,留应答间隙)。 参数热切换;半双工 RS485 友好(每周期最多写一帧,留应答间隙)。
- **协议解析**(可关,关=原始透传): - **协议解析**(可关,关=原始透传):
增量字节流定界 + CRC 校验,失败逐字节重同步,噪声自动丢弃; 增量字节流定界 + CRC 校验,失败逐字节重同步;
接收帧 ID 位按 DLPilot 语义译码显示(0xF0=成功、0xFD=参数越界…)。 接收帧 ID 位按 DLPilot 语义译码显示(0xF0=成功、0xFD=参数越界…)。
**解析模式不吞数据**:不成帧的字节(纯文本设备、波特率不符的乱码、
总线噪声)以原始行照常显示,协议解析只是附加层,不影响串口助手本体。
- **帧构成定制**(协议卡,发送/接收共用同一布局,仅包头值分方向): - **帧构成定制**(协议卡,发送/接收共用同一布局,仅包头值分方向):
帧构成用**令牌序列**定义,默认 `HDR ID LEN PAYLOAD CRC`(即 DLPilot 五段式): 帧构成用**令牌序列**定义,默认 `HDR ID LEN PAYLOAD CRC`(即 DLPilot 五段式):
@@ -36,7 +38,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 轴自动或手动量程,暂停/清空;绘图区可**弹成独立窗口**("弹出"按钮,
+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)
+35 -15
View File
@@ -30,16 +30,22 @@ 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_garbage(data, ts) 解析模式: 不成帧的原始 bytes(重同步丢弃字节, 不吞数据);
on_tx(label, data) 发送回显: label=payload 字符串(自动包装)/None(HEX直发) ts=到达时刻
on_raw(data, ts) 透传模式: 原始接收 bytes; ts=到达时刻
on_tx(label, data, ts) 发送回显: label=payload 字符串(自动包装)/None(HEX直发);
ts=写入串口时刻
时间戳必须由本模块在读线程打(字节到达时刻), GUI 消费侧再打会随 UI 卡顿
成批挤压失真——日志时间就不再是到达时间。
""" """
def __init__(self, cfg, on_status): def __init__(self, cfg, on_status):
self.on_status = on_status self.on_status = on_status
self.on_frame = None self.on_frame = None
self.on_garbage = None
self.on_raw = None self.on_raw = None
self.on_tx = None self.on_tx = None
self.q = queue.Queue() self.q = queue.Queue()
@@ -100,7 +106,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 +129,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 +154,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 +165,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 +186,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 +198,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 +211,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 +224,12 @@ 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)
# 不成帧的字节也透出: 串口助手是本体、协议解析是附加层, 解析不能
# 让收到的字节凭空消失(纯文本设备/波特率不符的乱码全在这里可见)。
# drain 必须无条件执行, 未接回调时也不能在解析器里无限累积。
junk = self.parser.drain_garbage()
if junk and self.on_garbage is not None:
self.on_garbage(junk, ts)
elif self.on_raw is not None: elif self.on_raw is not None:
self.on_raw(data) self.on_raw(data, ts)
+116 -40
View File
@@ -8,7 +8,8 @@ SerialMate — RS485/串口调试助手(Fluent UI)
功能: 功能:
- 串口收发, 参数热切换; 半双工 RS485 友好(每个工作周期最多写一帧, 留应答间隙) - 串口收发, 参数热切换; 半双工 RS485 友好(每个工作周期最多写一帧, 留应答间隙)
- 接收: 协议解析(包头+LEN 定界+CRC 校验, 坏帧重同步) / 原始透传 两模式切换 - 接收: 协议解析(包头+LEN 定界+CRC 校验, 坏帧重同步; 不成帧字节以原始行透出,
不吞数据) / 原始透传 两模式切换
- payload 显示 字符串/HEX 切换, 原始帧 hex 可选, 日志可保存 - payload 显示 字符串/HEX 切换, 原始帧 hex 可选, 日志可保存
- 曲线: 4 通道, 二进制定长 / 文本数字 / 正则 三种提取方式 + 倍率 - 曲线: 4 通道, 二进制定长 / 文本数字 / 正则 三种提取方式 + 倍率
- 发送: 自动包装(payload 即协议载荷, '/' 分隔多条依次组帧) / HEX 直发, - 发送: 自动包装(payload 即协议载荷, '/' 分隔多条依次组帧) / HEX 直发,
@@ -49,7 +50,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.0' APP_VERSION = '0.1.2'
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,10 +70,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 +246,18 @@ 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_garbage = 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_raw = lambda b, ts: self.events.put(('raw', b, ts))
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)
@@ -454,6 +472,9 @@ class Window(QMainWindow):
self.combo_crc.currentIndexChanged.connect(self._apply_proto) self.combo_crc.currentIndexChanged.connect(self._apply_proto)
self.combo_scope.currentIndexChanged.connect(self._apply_proto) self.combo_scope.currentIndexChanged.connect(self._apply_proto)
self.chk_parse = CheckBox('解析协议帧(收)') self.chk_parse = CheckBox('解析协议帧(收)')
self.chk_parse.setToolTip(
'勾选=按协议格式解析, 合法帧结构化显示; 不成帧的字节不丢弃, 以原始行透出\n'
'不勾选=原始透传, 收到什么显示什么(基础串口助手模式)')
self.chk_parse.stateChanged.connect(self._on_parse_toggle) self.chk_parse.stateChanged.connect(self._on_parse_toggle)
self._row(lay, CaptionLabel('CRC'), self.combo_crc, self._row(lay, CaptionLabel('CRC'), self.combo_crc,
CaptionLabel('覆盖'), self.combo_scope, self.chk_parse) CaptionLabel('覆盖'), self.combo_scope, self.chk_parse)
@@ -634,7 +655,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 +845,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 +1267,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 +1383,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])
+19 -3
View File
@@ -331,6 +331,9 @@ class FrameParser:
"""增量字节流 -> 完整帧: 前缀令牌逐位对齐(帧头/字面量, ID 为通配) + LEN 定长 """增量字节流 -> 完整帧: 前缀令牌逐位对齐(帧头/字面量, ID 为通配) + LEN 定长
+ CRC 校验; 任一处失配丢 1 字节重新同步。LEN 值经口径换算后即 payload 长度, + CRC 校验; 任一处失配丢 1 字节重新同步。LEN 值经口径换算后即 payload 长度,
负值视为伪帧头逐出 —— 伪帧头最多滞留一帧即被逐出重同步, 对调试工具可接受。 负值视为伪帧头逐出 —— 伪帧头最多滞留一帧即被逐出重同步, 对调试工具可接受。
丢弃字节不销毁: 累积进 self.garbage, 由上层 drain_garbage() 取走透出 ——
本工具首先是串口助手, 协议解析是附加层, 不成帧的字节也必须可见。
(仅匹配上前缀、还在等后续字节的"半帧"缓冲不算垃圾, 可能补全成真帧)
""" """
def __init__(self, cfg): def __init__(self, cfg):
@@ -349,9 +352,22 @@ class FrameParser:
self._len_pos = lay.len_pos self._len_pos = lay.len_pos
self._extra = lay.len_extra(cfg.len_sem, cs) self._extra = lay.len_extra(cfg.len_sem, cs)
self._buf = bytearray() self._buf = bytearray()
self.garbage = bytearray() # 重同步丢弃的非帧字节(调用方定期取走)
def clear(self): def clear(self):
self._buf.clear() self._buf.clear()
self.garbage.clear()
def drain_garbage(self):
"""取走累积的非帧字节(bytes)并清空; 解析模式不吞数据的取数入口"""
out = bytes(self.garbage)
self.garbage.clear()
return out
def _drop_head(self):
"""丢缓冲首字节重同步; 丢弃字节记入 garbage(可能正是用户要看的输出)"""
self.garbage.append(self._buf[0])
del self._buf[:1]
def _aligned(self): def _aligned(self):
"""把缓冲对齐到前缀匹配; 字节不足且已匹配部分则保持等待(返回 True), """把缓冲对齐到前缀匹配; 字节不足且已匹配部分则保持等待(返回 True),
@@ -365,7 +381,7 @@ class FrameParser:
break # 失配 -> 丢 1 字节 break # 失配 -> 丢 1 字节
else: else:
return True return True
del buf[:1] self._drop_head()
if not buf: if not buf:
return False return False
@@ -378,7 +394,7 @@ class FrameParser:
break # LEN 字节未到齐 break # LEN 字节未到齐
plen = self._buf[self._len_pos] - self._extra plen = self._buf[self._len_pos] - self._extra
if plen < 0: # LEN 为负 -> 伪帧头, 逐出重同步 if plen < 0: # LEN 为负 -> 伪帧头, 逐出重同步
del self._buf[:1] self._drop_head()
continue continue
total = cfg.layout.total_size(plen, cfg.crc_size) total = cfg.layout.total_size(plen, cfg.crc_size)
if len(self._buf) < total: if len(self._buf) < total:
@@ -388,7 +404,7 @@ class FrameParser:
frames.append(cfg.parse_frame(raw)) frames.append(cfg.parse_frame(raw))
del self._buf[:total] del self._buf[:total]
else: else:
del self._buf[:1] # CRC 失配 -> 丢帧头重同步 self._drop_head() # CRC 失配 -> 丢帧头重同步
return frames return frames
+18
View File
@@ -93,6 +93,24 @@ frames = d.FrameParser(cfg).feed(bytes(bad) + good)
check('bad-crc then good', len(frames), 1) check('bad-crc then good', len(frames), 1)
check('recovered code', frames[0].code, 0xF0) check('recovered code', frames[0].code, 0xF0)
# 8b) 非帧字节不吞: 重同步丢弃的字节累积进 garbage 可整体取回(串口助手是本体,
# 协议解析只是附加层——纯文本设备接入时所有字节都从这里透出显示)
p = d.FrameParser(cfg)
check('纯文本不成帧', p.feed(b'hello serial\r\n'), [])
check('纯文本全入 garbage', p.drain_garbage(), b'hello serial\r\n')
check('garbage 取后清空', p.drain_garbage(), b'')
p = d.FrameParser(cfg)
frames = p.feed(b'\x00\x01' + good + b'tail')
check('噪声+帧+尾字节 帧数', len(frames), 1)
check('噪声与尾字节可取回', p.drain_garbage(), b'\x00\x01tail')
p = d.FrameParser(cfg)
p.feed(bytes(bad) + good)
check('坏CRC帧字节全透出', p.drain_garbage(), bytes(bad))
p = d.FrameParser(cfg)
p.feed(b'\xB5') # 只到帧头: 可能补全成真帧, 扣留不算垃圾
check('半帧头扣留待补全', p.drain_garbage(), b'')
check('补全后成帧', [f.code for f in p.feed(good[1:])], [0xF0])
# 9) 伪帧头与帧头字节入载荷 # 9) 伪帧头与帧头字节入载荷
# 9a 小 LEN 伪帧头(B5 AA 01 55,CRC 应为 00 而实为 01)失败后逐字节重同步到真帧 # 9a 小 LEN 伪帧头(B5 AA 01 55,CRC 应为 00 而实为 01)失败后逐字节重同步到真帧
frames = d.FrameParser(cfg).feed(b'\xB5\xAA\x01\x55\x01' + good) frames = d.FrameParser(cfg).feed(b'\xB5\xAA\x01\x55\x01' + good)
+48 -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,13 +162,43 @@ 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)
# 5b) 解析模式非帧字节: link.on_garbage 与透传同走 raw 事件, 日志不丢任何字节
# (v0.1.2 起协议解析是附加层, 串口助手本体语义优先)
check('on_garbage 已接线', win.link.on_garbage is not None, True)
win.events.put(('raw', b'\xAB\xCD', _time.time()))
win._drain_events()
check('非帧字节日志行', win.text_rx.toPlainText().splitlines()[-1].endswith('AB CD'), True)
# 6) 未连接发送丢弃 # 6) 未连接发送丢弃
win._send() 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']),
+30 -10
View File
@@ -3,7 +3,8 @@
"""链路自检(无硬件): pyserial loop:// 虚拟回环验证 组帧→发送→接收→解析→曲线提取 """链路自检(无硬件): pyserial loop:// 虚拟回环验证 组帧→发送→接收→解析→曲线提取
==================================================================== ====================================================================
缺 pyserial 时跳过(退出码 0)。loop:// 回环口写即读, 发出的帧同样回到 缺 pyserial 时跳过(退出码 0)。loop:// 回环口写即读, 发出的帧同样回到
接收侧, 但 TX 帧头(D5)不是 RX 帧头(B5), 会被解析器当噪声丢弃, 不影响判定。 接收侧, 但 TX 帧头(D5)不是 RX 帧头(B5), 会作为非帧字节经 on_garbage 透出
(v0.1.2 起解析模式不吞数据), 不影响判定。
用法: python selftest_loop.py (全部通过退出码 0) 用法: python selftest_loop.py (全部通过退出码 0)
""" """
@@ -38,17 +39,19 @@ def wait_for(pred, timeout=5.0):
return False return False
got = {'status': [], 'frames': [], 'tx': [], 'raw': []} got = {'status': [], 'frames': [], 'tx': [], 'raw': [], 'garbage': []}
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_garbage = lambda b, ts: got['garbage'].append((b, ts))
link.on_tx = lambda lb, data: got['tx'].append((lb, data)) link.on_raw = lambda b, ts: got['raw'].append((b, ts))
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 +61,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,19 +69,35 @@ 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)
check('曲线提取(×0.1)', v is not None and abs(v - 24.6) < 1e-9, True) check('曲线提取(×0.1)', v is not None and abs(v - 24.6) < 1e-9, True)
# 3b) 解析模式下非帧字节不吞: 以 garbage 原样透出(带到达时刻)——
# 纯文本设备接入解析模式也全可见, 这是 v0.1.2 的核心语义
link.ser.write(b'plain text\n')
check('解析模式非帧字节透出',
wait_for(lambda: b'plain text\n'
in b''.join(b for b, _t in got['garbage'])), True)
if got['garbage']:
check('非帧字节携带到达时刻',
isinstance(got['garbage'][-1][1], float)
and 0 < time.time() - got['garbage'][-1][1] < 60, True)
# 回环回来的 TX 帧(D5 头)在解析模式下同样只算非帧字节, 不应进 frames
check('回环TX帧不算合法帧', all(fr.raw[0] == 0xB5 for fr, _t in got['frames']), True)
# 4) 透传模式切换: 关解析后收到原始字节 # 4) 透传模式切换: 关解析后收到原始字节
link.set_parse(False) link.set_parse(False)
raw = bytes([0x11, 0x22, 0xB5, 0x00]) 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 +105,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)
+114 -12
View File
@@ -27,16 +27,35 @@
- **SerialMate**:RS485/串口调试助手,帧格式(包头/板ID/LEN/CRC)可定制, - **SerialMate**:RS485/串口调试助手,帧格式(包头/板ID/LEN/CRC)可定制,
默认兼容 DLPilot 光机协议;payload 字符串/HEX 显示、4 通道曲线、payload 默认兼容 DLPilot 光机协议;payload 字符串/HEX 显示、4 通道曲线、payload
自动包装发送。 自动包装发送。
- **I2C-Lab**(2026-10-02 新启,即 §3.5 三合一需求落地):I2C 总线监控调试台。
阶段一=纯被动监控:RP2040 probe 固件(pico-sdk C,PIO 边沿捕获 + 核1 实时
解码 + USB CDC `=`/`!` 命令行,250MHz)+ PC GUI(Fluent,事务流/地址过滤/
CSV/逻辑分析仪式波形快照/演示模式)。主机读写(复活 I2C-Inject 择机注入)
与从机模拟在路线图。解码语义权威 `I2C-Lab/src/i2cdec.py`。
- **I2C-Inject**:RP2040 固件 + PC GUI,demo 板 I2C 总线择机注入读写寄存器。 - **I2C-Inject**:RP2040 固件 + PC GUI,demo 板 I2C 总线择机注入读写寄存器。
**2026-09-25 起暂停开发**,代码与固件保留备查。 **2026-09-25 起暂停开发**,代码与固件保留备查;**2026-10-02 起后续开发并入
I2C-Lab**(接线/回显约定与解码算法已继承过去)。
- 子模块引用:`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 不同步)。
- 发布约定:源码与打包产物(exe、运行配置)入库;发布 zip 不入库,只上传 Gitea Release。 - 图标约定(2026-10-10 立):全部工具共用 **logo 家族版式**(512 黑底 + 工具色圆角块
+ 上行域词黑字/下行角色词白字,Arial Bold 146px 基准自适应;颜色不参与家族识别,
现分配橙 `#FF9900`×4、绿 `#00C878`×2、青 `#00C8D7`×1,紫 `#A78BFA` 暂未分配);软件内按钮只用
FluentIcon 白名单、文本内用单色 Unicode 符号(→ ← ⚠ ⏳ ⚙)。**唯一权威 `icons/`**:
约定文档 `icons/README.md` + 统一生成器 `icons/make_icon.py`(含 `--check`
复现校验与 `--preview` 家族预览图;各工具内旧 make_ico 脚本保留作历史对照,不再维护)。
- 发布约定:源码与运行配置入库;**exe/zip 等交付物一律不入库**(2026-10-13 起
`.gitignore` 全局挡 `*.exe`;历史 39 个 exe blob 已用 git filter-repo 清除,
仓库 1.1 GiB → ~1 MB (pack 实测)),发布只上传 Gitea Release 附件。
## 2. 开发进度(已完成功能清单) ## 2. 开发进度(已完成功能清单)
### 2.1 DLPilot — 最新发布 v0.4.0(2026-09-24) ### 2.1 DLPilot — 最新发布 v0.4.3(2026-10-13)
- HDMI 源端:EDID 查看/逐字节解析、分辨率切换、智能显示拓扑(仅拉回转接板屏)、 - HDMI 源端:EDID 查看/逐字节解析、分辨率切换、智能显示拓扑(仅拉回转接板屏)、
纯色/黑白/测试卡全屏投屏、自定义槽位(pattern_slots.json)。 纯色/黑白/测试卡全屏投屏、自定义槽位(pattern_slots.json)。
- 光机控制:RS485 链路(`dlpc_rs485.py` 帧协议+线程)、开关光机/设光强/TEC 控温/ - 光机控制:RS485 链路(`dlpc_rs485.py` 帧协议+线程)、开关光机/设光强/TEC 控温/
@@ -47,6 +66,11 @@
(Fluent 主题 + Qt 调色板 + QSS + DWM 标题栏三件套同步)。 (Fluent 主题 + Qt 调色板 + QSS + DWM 标题栏三件套同步)。
- 发布:v0.1.0(HDMI-Tool)→ v0.2.0 → v0.3.x → v0.4.0 均已上传 Gitea Release - 发布:v0.1.0(HDMI-Tool)→ v0.2.0 → v0.3.x → v0.4.0 均已上传 Gitea Release
(v0.3.6 未单独发布,功能被 v0.4.0 覆盖);远端标签与附件已核对。 (v0.3.6 未单独发布,功能被 v0.4.0 覆盖);远端标签与附件已核对。
- **v0.4.3(2026-10-13)**:修复长会话越跑越卡/命令无响应——收发日志手工
cursor 裁剪用实例级枚举访问,PySide6 6.11 必抛 AttributeError 且异常打断
`_poll_dlpc` 队列消费(FilmForge 同案);改 `setMaximumBlockCount(500)`
环形裁剪 + 逐事件兜底 + 新增 `selftest_log.py` 回归。tag `DLPilot-v0.4.3`
指向 `296e220`(历史哈希为 filter-repo 重写前记录)。详见 `DLPilot/AGENTS.md` §9。
### 2.2 EDID-Viewer — 最新发布 v0.1.0(2026-09-25) ### 2.2 EDID-Viewer — 最新发布 v0.1.0(2026-09-25)
- 解析:EDID 1.x 基础身份/时序、CTA(音频 SAD/HDMI VSDB/CEC/HF-VSDB)、HDR 与 - 解析:EDID 1.x 基础身份/时序、CTA(音频 SAD/HDMI VSDB/CEC/HF-VSDB)、HDR 与
@@ -61,10 +85,10 @@
- 工程化:目录与发布流水线对齐 DLPilot(onefile exe 入库);标签 - 工程化:目录与发布流水线对齐 DLPilot(onefile exe 入库);标签
`EDID-Viewer-v0.1.0` 与 Release 附件(66,483,477 字节)已核对。 `EDID-Viewer-v0.1.0` 与 Release 附件(66,483,477 字节)已核对。
### 2.3 SerialMate — v0.1.0(2026-09-27,首版) ### 2.3 SerialMate — 最新发布 v0.1.2(2026-10-16)
- **更名**:原名 SerialAssistant(目录/exe/文档/配置同步更名;旧配置 - **更名**:原名 SerialAssistant(目录/exe/文档/配置同步更名;旧配置
serial_assistant.json 首次启动自动迁移为 serial_mate.json;图标与名字 serial_assistant.json 首次启动自动迁移为 serial_mate.json;更名时的图标为
无绑定为波形+RS485 母题)。用户确认 "SerialMate 吧"(2026-09-27)。 波形+RS485 母题,后统一为家族版式,见 §2.6)。用户确认 "SerialMate 吧"(2026-09-27)。
- **已发布**:commit d133906 推送 main 并打 tag `SerialMate-v0.1.0`, - **已发布**:commit d133906 推送 main 并打 tag `SerialMate-v0.1.0`,
Release 附件 `SerialMate-v0.1.0-win64.zip`(63.7 MB)已上传核对。 Release 附件 `SerialMate-v0.1.0-win64.zip`(63.7 MB)已上传核对。
- 协议:**帧构成令牌序列可定制**(`HDR ID LEN PAYLOAD CRC` + 十六进制字面量 - 协议:**帧构成令牌序列可定制**(`HDR ID LEN PAYLOAD CRC` + 十六进制字面量
@@ -86,18 +110,73 @@
发送卡一键切换/新建/存为/删除/表格编辑/重载。 发送卡一键切换/新建/存为/删除/表格编辑/重载。
- 发布:SerialMate.exe 已入库(随帧构成功能重建),zip/Release 未发布 - 发布:SerialMate.exe 已入库(随帧构成功能重建),zip/Release 未发布
(待用户验收后走 package_release + publish_gitea)。 (待用户验收后走 package_release + publish_gitea)。
- **v0.1.1(2026-10-13)发布**:接收路径修复(时间戳改 link 线程到达时刻打戳、
接收日志批量插入+5000 行上限+积压折叠、通道配置缓存),详见
`SerialMate/ISSUE_rx_timestamp_and_lag.md` 与其 AGENTS.md §7。tag
`SerialMate-v0.1.1` 指向 `e5b2e45`(历史哈希为重写前记录),Release 附件
`SerialMate-v0.1.1-win64.zip`(63.6 MB)。
- **v0.1.2(2026-10-16)发布**:**解析模式不再吞数据**——FrameParser 重同步
丢弃字节累积 garbage 经 link `on_garbage` 上抛,GUI 与透传行同格式显示;
起因用户接非 DLPilot 协议设备开解析模式接收区空白,用户明确定位
"串口助手是本体、协议解析是附加项"(半帧前缀扣留语义有意保留,详见其
AGENTS.md 不变量 5a)。tag `SerialMate-v0.1.2` 指向 `8e91e66`,Release
附件 `SerialMate-v0.1.2-win64.zip`(63.6 MB)已上传核对。
### 2.4 I2C-Inject — ⏸ 暂停开发(2026-09-25) ### 2.4 I2C-Inject — ⏸ 暂停开发(2026-09-25)
- 已完成:RP2040 注入器固件(MicroPython,内置 viper 总线嗅探)、PC 端 GUI、 - 已完成:RP2040 注入器固件(MicroPython,内置 viper 总线嗅探)、PC 端 GUI、
demo 板寄存器语义实测(0x606E bit5=镜像 D[7:0]、0x606D 整组顺序,逻辑分析仪 demo 板寄存器语义实测(0x606E bit5=镜像 D[7:0]、0x606D 整组顺序,逻辑分析仪
双色交叉验证)。 双色交叉验证)。
- 状态:暂停开发,代码与固件保留备查,README 顶部有状态横幅。 - 状态:暂停开发,代码与固件保留备查,README 顶部有状态横幅;后续开发并入
I2C-Lab(2026-10-02,见 §2.5/§3.5)。
### 2.5 I2C-Lab — v0.1.0 已就绪(2026-10-02,待真机验收后发布)
- **定位**:I2C 总线监控调试台(原 §3.5 三合一需求的监控阶段);三区目录 +
双文档对齐全仓约定;I2C-Inject 定位为留档,本工具独立续接。
- **固件**(`firmware/`,pico-sdk C,PICO_BOARD=pico):单 PIO SM `bus_watch`
(变沿即压 FIFO,push noblock,~40ns 分辨率)+ 核1 时间戳/解码状态机(语义
与 i2cdec.py 一致,含 56 字节长事务截断)+ 核0 USB CDC CLI(?/v/mon/
stats/wave/boot);250MHz/vreg 1.15V;边沿环 16k(wave 快照,暂停换一致性)
+ 事务环 256。
- **PC 端**(`src/`):i2cdec.py(边沿→事务/T·W 行语法/CSV/参考编码器,自测
73 项全绿:100k/400k 往返+时间戳对齐、随机鲁棒、截断 L 标志)、probelink.py
(行协议链路线程)、waveview.py(QPainter 波形:滚轮缩放/拖动/双击适配/
START-STOP 刻线/事务标签)、main.py(Fluent 双栏:串口卡/监控卡/事务表/
波形卡/固件日志 + 演示模式)、theme/config 同源;selftest_gui.py 29 项全绿;
build/package/publish 三件套适配(发布 zip 含 UF2)。
- **构建链(本机打通并记录踩坑)**:pico-sdk 2.3.1(ghproxy 代理克隆)+
tinyusb 0.18.0(gitee 镜像)+ Arm GNU 13.3;pioasm 宿主编译用 ziglang
(pip 装,预置 CMakeCache 分号列表法);UF2 用自制 make_uf2.py(objcopy→bin
→UF2,往返自检 + 与真机 MicroPython UF2 逐字段核对一致),绕开 picotool
的 github 依赖。
- **产物**:I2C-Lab.exe(63.8MB)+ firmware/i2c_lab.uf2/.bin 保留本地
release/(交付物不入库,`.gitignore` 已挡,2026-10-13 起);
zip/Release 待真机验收后走流水线(tag `I2C-Lab-v0.1.0`)。
### 2.6 仓库级基础设施 — 图标家族约定与统一生成器(2026-10-10)
- **`icons/` 目录**:`README.md`(风格与内容约定 + 应用点总表 + 新工具接入清单)
+ `make_icon.py`(六工具统一生成器,吸收 DLPilot/EDID-viewer/SerialMate/
I2C-Inject/FlashTool 五份分散脚本)+ `preview.png` 家族预览图。
- **复现验证**:`--check` 逐像素比对,DLPilot/EDID-viewer/SerialMate/I2C-Inject
全 SAME(EDID 的 134/116px 历史版式以显式 override 保真)。
- **修正 I2C-Lab 图标**:第二行自 I2C-Inject 复制 SVG 后一直误为 "Inject",
已改 "Lab"(png/ico/svg 重生成;exe 为入库旧图,下次 build_exe 打包生效)。
- **发现待办(子模块内)**:FlashTool 图标 "FLASH" 146px 宽 485px 超出 436px
色块(两端黑字落黑底不可见),家族自适应规则可修正,但需在其原仓库执行并提交。
- **FilmForge 图标(2026-10-10)**:热压模具温控监视台(独立仓
`E:\Hulk-Coding\FilmForge`,GD32F4 固件 + GUI 上位机)按家族版式出图——
上行 `FILM`/下行 `Forge`、青 `#00C8D7`(取自其温度曲线/主题青,按钮 `#29F1FF`
太亮故取深一档;与其 Dark 曲线配色常量一致),产物落在其
`GUI/logos/filmforge.{png,ico,svg}`(`build_exe.py:30` 预留路径);
并在其 `GUI/app_fluent.py` 补上运行时 `setWindowIcon`(_MEIPASS/_源码双路,
此前缺失),`selftest_gui.py` 48 项 ALL PASS。图标改动需在其原仓库提交。
## 3. 开发需求与计划(已提出、尚未实现) ## 3. 开发需求与计划(已提出、尚未实现)
### 3.1 DLPilot ### 3.1 DLPilot
- 【待素材】校准图案槽位预置为空:第二排 5 个槽位为 3D 光固化测试/校准图预留 - ~~【待素材】校准图案~~(已完成,2026-10-13 `74469cf`):CeramixNano 系列 6 张
(`file: null`);素材到位后 assets/ → 生成脚本入库 → 更新 slots → 重新打包。 校准图(黑/白/caliPoint/circle_640p/contrast/Sharpness_XY_3)二进制直存
assets/+release/ 平铺(用户素材,无生成脚本),slots 栏位接入、
package_release 收集规则扩展;随 v0.4.3 之后的下次发布进 zip。
- 【待真机帧·低优先】M305 回归向量是按协议推演的(selftest_rs485.py:90 起, - 【待真机帧·低优先】M305 回归向量是按协议推演的(selftest_rs485.py:90 起,
0x8CA0=36000mW 为推演值):真机功率显示已实测对接,仅剩把自测向量换成真机抓包, 0x8CA0=36000mW 为推演值):真机功率显示已实测对接,仅剩把自测向量换成真机抓包,
让无硬件回归更贴近真机。 让无硬件回归更贴近真机。
@@ -126,13 +205,28 @@
### 3.3 SerialMate ### 3.3 SerialMate
- 【待真机】光机实机收发与温度曲线实测(M201 ×0.1 提取),当前仅回环验证。 - 【待真机】光机实机收发与温度曲线实测(M201 ×0.1 提取),当前仅回环验证。
- 【无图标】assets/serial-mate.ico 未生成(build_exe.py 已预留路径)。 - ~~【无图标】assets/serial-mate.ico 未生成~~(已生成并随家族统一,见 §2.6)。
- 发送批处理脚本化(延时/循环/等待应答);曲线数据导出 CSV。 - 发送批处理脚本化(延时/循环/等待应答);曲线数据导出 CSV。
### 3.4 I2C-Inject ### 3.4 I2C-Inject
- 暂停开发期间不列计划;恢复时以其 README 现状为准。 - 并入 I2C-Lab 路线(见 §3.5);本目录不再单列计划。
### 3.5 明确不做(决策,非待办) ### 3.5 I2C-Lab 后续阶段(2026-10-02 起续接,监控已就绪见 §2.5)
- 【下一阶段】**主机模式**:probe 作 I2C 主机——总线扫描(scan)、读写
(wr/rd),复活 I2C-Inject 择机注入语义(静置窗口避让主控轮询/重试/读回
校验);GUI 加寄存器读写卡。硬件 I2C 与嗅探 PIO 可并存(先停监控再发射,
或 PIO 分频采样回看自身发射)。
- 【规划】**从机模拟**:RP2040 硬件 I2C 从机模式 + PC 端应答表(寄存器值/
回调),顶替传感器验证主机驱动。
- 【监控增强】协议语义插件(LT8619C bank/寄存器注释)、触发抓取、长记录
落盘、SMBus PEC/10bit 地址注记、波形按事务着色。
- 【待真机验收 v0.1.0】清单见 `I2C-Lab/AGENTS.md` §3(枚举/实听/注入对照/
400kHz 波形/30min 稳定性),验收后打 tag 发布。
- 平台结论(2026-09-30 沿用):固件 pico-sdk C(MicroPython 无从机 API、
嗅探无法实时流式);硬件 2× BPI-Pico-RP2040 + 2× JI2C;GP8=SDA GP9=SCL、
USB CDC `=`/`!` 回显、挂 live 板只接 GND/SDA/SCL 不加上拉——均已落地。
### 3.6 明确不做(决策,非待办)
- **0x606E 的 0x40/0x80 位交换不再单独实测**(2026-09-25 用户确认:当前行为正常、 - **0x606E 的 0x40/0x80 位交换不再单独实测**(2026-09-25 用户确认:当前行为正常、
符合预期,bit5 实测基线已够用;原【待硬件】项关闭)。 符合预期,bit5 实测基线已够用;原【待硬件】项关闭)。
- TI 原始 I2C 命令透传不做:GD32 网关固件只收 ASCII M 命令集(v0.2.0 显式决策)。 - TI 原始 I2C 命令透传不做:GD32 网关固件只收 ASCII M 命令集(v0.2.0 显式决策)。
@@ -183,6 +277,7 @@ CustomTkinter;补齐折叠、PD 状态颜色、Light 调色板与 Qt 单文件
| 12 | Fluent Light 主题发黑 | 只调 `setTheme` 仅刷新 Fluent 控件,QMainWindow/原生 QTabWidget/QStatusBar/标题栏仍跟系统深色 → **三件套**:setTheme + QPalette/应用级 QSS + DWM 属性 20/35/36(EDID-Viewer `theme.py`,DLPilot 同源) | | 12 | Fluent Light 主题发黑 | 只调 `setTheme` 仅刷新 Fluent 控件,QMainWindow/原生 QTabWidget/QStatusBar/标题栏仍跟系统深色 → **三件套**:setTheme + QPalette/应用级 QSS + DWM 属性 20/35/36(EDID-Viewer `theme.py`,DLPilot 同源) |
| 13 | PyInstaller 传 .spec 报错 | 给 .spec 再传 `--specpath` 报 "makespec options not valid" → spec 由 build_exe.py 生成后只传 distpath/workpath | | 13 | PyInstaller 传 .spec 报错 | 给 .spec 再传 `--specpath` 报 "makespec options not valid" → spec 由 build_exe.py 生成后只传 distpath/workpath |
| 14 | onefile 冒烟"没窗口" | onefile 引导父进程 MainWindowTitle 恒为空,轮询父进程误判失败 → 按进程名找子进程窗口(EDID-Viewer 实测 ~2s 起窗) | | 14 | onefile 冒烟"没窗口" | onefile 引导父进程 MainWindowTitle 恒为空,轮询父进程误判失败 → 按进程名找子进程窗口(EDID-Viewer 实测 ~2s 起窗) |
| 15 | PySide6 6.11 实例级枚举访问 | 手工 cursor 裁剪 `cursor.Start` 在 6.11 必抛 AttributeError(实例级访问被移除,类级简写 `Qt.Xxx` 仍可用);槽内异常不崩溃只静默失败,还会打断同函数后半段与外层消费循环 → 日志裁剪一律用 `setMaximumBlockCount`,队列消费逐事件兜底 try/except。DLPilot v0.4.2 中雷(v0.4.3 修),SerialMate v0.1.0 末期同案 |
## 5. 快速上手(完整规程见各工具 AGENTS.md) ## 5. 快速上手(完整规程见各工具 AGENTS.md)
@@ -206,6 +301,13 @@ python selftest.py ; python selftest_loop.py # 协议回归 + 链路
python main.py --seconds 5 # GUI 冒烟 python main.py --seconds 5 # GUI 冒烟
python build_exe.py ; python package_release.py 0.1.0 # 打包 + 组装 zip python build_exe.py ; python package_release.py 0.1.0 # 打包 + 组装 zip
python publish_gitea.py ../release/SerialMate-v0.1.0-win64.zip --insecure python publish_gitea.py ../release/SerialMate-v0.1.0-win64.zip --insecure
# I2C-Lab(完整清单:I2C-Lab/AGENTS.md)
cd I2C-Lab/src
python selftest.py ; python selftest_gui.py # 解码回归 + GUI 结构自检(改 i2cdec/main 后必跑)
python main.py --seconds 5 --demo # GUI 冒烟(演示模式, 无硬件)
python build_exe.py ; python package_release.py 0.1.0 # 打包 + 组装 zip(含固件 UF2)
python publish_gitea.py ../release/I2C-Lab-v0.1.0-win64.zip --insecure
``` ```
上游协议/寄存器语义问题先查上游仓 `protocols.md` 与 `doc/architecture/`; 上游协议/寄存器语义问题先查上游仓 `protocols.md` 与 `doc/architecture/`;