27 Commits
Author SHA1 Message Date
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
hulk 8fde79c071 fix: v0.4.2 串口日志清空按钮并入标题行对齐;恢复光机命令快捷按钮排(开/关光机等7个, v0.4.0 重写时丢失);重打包 2026-09-28 18:51:48 +08:00
hulk b4328b8be6 docs(agent): 记录 DLPilot v0.4.1 已发布(tag 指向 4149d20) 2026-09-28 15:26:49 +08:00
hulk 4149d203b0 fix: v0.4.1 修复主题切换丢 swatch 底色与连接失败长错误串撑爆布局;新增 selftest_theme;重打包 2026-09-28 15:20:11 +08:00
hulk 264a0c74a6 docs(readme): 工具一览表增加各工具图标列;更新日期戳 2026-09-28 11:00:18 +08:00
hulk 190cf29aa5 chore: 仓库更名 LT8619C_Debug_Tools → HW-Debug-Tools
- Gitea 仓库已改名(旧地址自动重定向),git remote 同步更新
2026-09-28 10:01:16 +08:00
hulk d658fd459a feat: 图标改为家族版式绿色款(同 FlashTool 色系)并重打包
- 黑底/绿色圆角框 rgb(0,200,120)/框内黑字 Serial + 白字 Mate, 与 DLPilot/EDID-Viewer/FlashTool 同版式
2026-09-28 09:37:54 +08:00
hulk b627f79dad docs(agent): 记录 SerialMate v0.1.0 已推送并发布 Release 2026-09-27 18:58:44 +08:00
hulk c26d2cdbaa feat: 新增 SerialMate 串口调试助手 v0.1.0
- 协议帧构成令牌序列可定制(包头/板ID/LEN口径/5种CRC x 3种覆盖), 默认兼容 DLPilot 光机协议
2026-09-27 18:52:54 +08:00
hulk 372cb6b7a0 docs: 关闭 0x606E 0x40/0x80 位交换实测待办(用户确认现状符合预期);M305 自测向量降为低优先 2026-09-25 02:12:25 +08:00
hulk 68fb81fb13 docs(agent): 记录画面镜像新需求 —— DLPC3421 14h 原生支持横/纵独立翻转,含 39h 命名陷阱警示 2026-09-25 02:08:44 +08:00
hulk 733847a58c docs: agent 文档体系按用户级默认工作约定重整为开发需求与进度管理 2026-09-25 01:48:18 +08:00
hulk f594f60fdd docs(I2C-Inject): 标记暂停开发 —— 代码与固件保留备查,根 README 同步状态 2026-09-25 01:41:43 +08:00
hulk e92fa9f349 docs: 根 README 补充 EDID-Viewer(工具一览/环境/目录/发布流程) 2026-09-25 01:40:17 +08:00
hulk fe4bca5a33 feat(EDID-Viewer): v0.1.0 入库 — Fluent EDID 查看器,目录对齐 DLPilot
- src/ 人写代码:main.py(Fluent 界面、Light/Dark/dark1/dark2/跟随系统主题、
  关于按钮;主题三件套 = setTheme + QPalette/应用级 QSS + DWM 标题栏,
  修复 Light 下窗口背景/页签/标题栏仍为系统深色的问题)、edid.py
  (EDID/CTA/HDMI/HDR 解析)、windows_edid.py(WMI 只读)、theme.py、
  test_edid.py(9 项解析测试,含随机畸形数据无崩溃检查)
- assets/ 图标与生成脚本;release/ 交付:EDID-Viewer.exe(onefile 63.7MB,
  启动/图标/主题已验证)+ README,与 DLPilot/release 同约定 exe 直接入库
- 流水线对齐 DLPilot:src/build_exe.py(spec 由脚本生成不入库,剔除 PATH 上
  Poppler/Conda 版本化 ICU,避免 QtCore 报 WinError 127)→
  package_release.py(组装 EDID-Viewer-v*-win64.zip,zip 只上传 Gitea)→
  publish_gitea.py(token 走 GITEA_TOKEN,自签证书 --insecure)
- agent.md 更名 AGENTS.md(DLPilot 双文档约定:README 给人 / AGENTS.md 给
  Agent);根 .gitignore 增加 EDID-viewer/release/*.zip
- validation/ 保留 EDID 本机/合成样本与窗口截图、关于对话框自检脚本
2026-09-24 22:34:02 +08:00
hulk b58cdc42b6 feat(DLPilot): v0.4.0 — PySide6-Fluent-Widgets UI and Light theme 2026-09-24 19:09:24 +08:00
hulk 21c1b7bcc1 落盘agent.md文件 2026-09-20 00:26:02 +08:00
hulk 144730fb3a fix(DLPilot): 二进制载荷日志行以 hex 展示 —— 修复复制被 NUL 截断 2026-09-18 15:32:34 +08:00
hulk ca898be299 chore(DLPilot): v0.3.6 打包(M305 功率检测三步序列 + 功率 W 两位小数显示) 2026-09-18 15:16:42 +08:00
hulk 511e7dfa44 feat(DLPilot): M305 实测负载功率对接(功率检测三步序列) 2026-09-18 15:10:14 +08:00
96 changed files with 10939 additions and 132 deletions
+19 -1
View File
@@ -2,16 +2,34 @@
__pycache__/
*.pyc
# PyInstaller 工作目录(打包产物 exe 已直接入库, 构建中间物不入库)
# PyInstaller 工作目录(构建中间物不入库)
build/
dist/
*.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
DLPilot/release/*.zip
EDID-viewer/release/*.zip
SerialMate/release/*.zip
# 源码模式运行时的配置写入(config.BASE_DIR=src/), 与 release/ 下入库的预置栏位区分
DLPilot/src/pattern_slots.json
# SerialMate 无预置栏位, 运行态配置(最近串口/通道等)一律不入库
SerialMate/src/serial_mate.json
SerialMate/**/serial_assistant.json
SerialMate/release/serial_mate.json
# 板型档案(board_profiles.json)是用户本机数据, 首次运行自动生成默认值
SerialMate/src/board_profiles.json
SerialMate/release/board_profiles.json
# I2C-Lab 运行态配置不入库; 固件构建产物在 firmware/build/(build/ 规则已覆盖)
# release/ 交付物(exe/UF2/BIN/zip)不入库: 本地保留, 待真机验收发布时再走流水线
I2C-Lab/src/i2c_lab.json
I2C-Lab/release/
# 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
+132 -41
View File
@@ -1,14 +1,14 @@
# AGENTS.md — DLPilot 工程档案(供 AI/Agent 使用)
# AGENTS.md — DLPilot 工程档案(开发约束与进度)
> **本文档面向 AI/Agent**,是人类文档 [README.md](README.md) 的姊妹篇,两者不重叠:
> README 回答"这是什么、怎么用";本文回答"代码怎么组织、怎么构建发布、改动时哪些约束不能破坏"。
> 任何 Agent 在修改本目录代码前应先读完本文。
> 人类文档 [README.md](README.md) 回答"这是什么、怎么用";本文记录"代码怎么组织、
> 怎么构建发布、改动时哪些约束不能破坏",兼作本工具的进度记录。
> 修改本目录代码前先读完本文。仓库整体的开发需求与进度管理见根目录 `agent.md`。
## 1. 项目定位
仓库 `LT8619C_Debug_Tools` 是 LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板的**调试上位机工具集**,
仓库 `HW-Debug-Tools` 是 LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板的**调试上位机工具集**,
上游主仓库(固件)为 `hulk/LT8619C_DLPC3421_HDMI`(同 Gitea 实例 `gitea.hulk.wang`),
本仓库本机路径 `E:\Hulk-Coding\LT8619C_DLPC3421_HDMI`。
上游固件本机路径 `E:\Hulk-Coding\LT8619C_DLPC3421_HDMI`;工具仓库路径 `E:\Hulk-Coding\HW-Debug-Tools`。
DLPilot 工作在两端:HDMI **源端**(PC 显卡侧,EDID/分辨率/图案)与**光机控制端**
(RS485 → 板载 GD32E230 网关 → DLPC3421)。寄存器语义判读基线(0x606E 位交换 /
0x606D 整组顺序)与光机协议规程都在上游仓库 `doc/architecture/` 与 `protocols.md`,不在本仓库。
@@ -34,15 +34,17 @@ DLPilot/
| 文件 | 角色 |
|---|---|
| `src/hdmi_gui.py` | 入口: argparse 命令行分发(--dump/--edid-fields/--test-pattern/--dlpc-cmd/--seconds), GUI 委托 app_gui |
| `src/app_gui.py` | CustomTkinter 主界面: 双栏仪表盘(左 HDMI 源端五卡/右 光机四卡), 光强/定时快捷滑条 |
| `src/hdmi_gui.py` | 入口: argparse 命令行分发(--dump/--edid-fields/--test-pattern/--dlpc-cmd/--seconds), GUI 委托 app_fluent |
| `src/app_fluent.py` | PySide6-Fluent-Widgets 主界面: 双栏仪表盘(左 HDMI 源端五卡/右 光机五卡), 光强/定时快捷滑条 |
| `src/display_win.py` | Win32 显示器枚举/分辨率切换/智能拓扑(纯 ctypes) |
| `src/edid_parse.py` | EDID 1.4/CEA-861 解析(纯标准库, 上游 lt8619c_edid.c 以此为参考解码器) |
| `src/patterns.py` | 测试图案全屏投屏窗口(tkinter, ESC 退出, 测试卡区域 1:1 居中) |
| `src/config.py` | 运行目录定位(BASE_DIR)与 pattern_slots.json 读写(slots/area/serial 三键) |
| `src/dlpc_rs485.py` | 光机 RS485 协议模块: 帧构造/增量解析/译码表/预设命令/串口线程(DLPC485) |
| `src/selftest_rs485.py` | 光机协议自检(对照上游实测帧向量, 无硬件回归, 改协议代码后必跑) |
| `src/build_exe.py` | PyInstaller 打包 → `release/DLPilot.exe`(onefile/noconsole, 内嵌图标+pyserial+customtkinter) |
| `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/package_release.py` | 复制图案到 release/(平铺) → `release/DLPilot-v<ver>-win64.zip`(校验栏位引用完整性; release/ 与 zip 内容一致) |
| `src/publish_gitea.py` | 建/复用 Gitea Release 并上传 zip(tag 默认取 zip 文件名) |
| `src/hdmi_source.ps1/.bat` | 独立 PowerShell 命令行工具(自包含,与 GUI 无代码共享) |
@@ -51,17 +53,18 @@ DLPilot/
| `src/la_wave_analyzer*.py` | 逻辑分析仪 CSV 波形判读(2/3 通道,40MHz 像素时钟假设) |
| `src/logos/make_ico.py` | svg/png → ico(写在其自身目录) |
| `release/pattern_slots.json` | 预置栏位:西门子星/波带片/综合测试卡,area=640x360;`serial` 键记忆光机串口 |
| `UI参考布局.svg/.png` | 界面布局设计稿(app_gui.py 双栏布局的规格来源, 随分支入库) |
| `UI参考布局.svg/.png` | 界面布局设计稿(app_fluent.py 双栏布局的规格来源, 随分支入库) |
## 3. 代码不变量(改 hdmi_gui.py 必须维持)
1. **依赖分层**:CLI/显示/EDID/协议/配置模块(display_win/edid_parse/patterns/config/
dlpc_rs485)纯标准库;customtkinter 是 GUI(app_gui)的**硬依赖**(装了才能开界面,
CLI 不受影响)。可选依赖仍走"try-import + HAS_* 标志 + 缺失降级"模式:Pillow、
pyserial——缺失时对应功能禁用/提示安装,其余功能必须照常工作。
2. **模块边界**:hdmi_gui.py 只是入口, 不放实现; GUI 不直接碰 ctypes/winreg
(显示器走 display_win, EDID 走 edid_parse); pyserial 只准在 dlpc_rs485.py 里 import
(hdmi_gui/app_gui 经 `dlpc_rs485.HAS_SERIAL` 与 `DLPC485` 类间接使用)。
1. **依赖分层**:CLI/显示/EDID/协议/配置模块(display_win/edid_parse/config/
dlpc_rs485)纯标准库;PySide6-Fluent-Widgets 是 GUI(app_fluent)的**硬依赖**(装了
才能开界面,CLI 不受影响),GUI 图案窗口使用 Qt,CLI 图案窗口由 patterns.py 使用 tkinter 创建。可选依赖
仍走"try-import + HAS_* 标志 + 缺失降级"模式:Pillow、pyserial——缺失时对应功能
禁用/提示安装,其余功能必须照常工作。
2. **模块边界**:hdmi_gui.py 只是入口, 不放实现; GUI 的显示器/EDID 访问走 display_win/edid_parse;ctypes 仅用于
单实例锁、Windows 标题栏与 frozen DLL 加载; pyserial 只准在 dlpc_rs485.py 里 import
(hdmi_gui/app_fluent 经 `dlpc_rs485.HAS_SERIAL` 与 `DLPC485` 类间接使用)。
3. **BASE_DIR 语义**(定义在 config.py):frozen(PyInstaller onefile)时
`BASE_DIR=exe 所在目录`(`sys.executable`),源码运行时 `=config.py 所在目录`
(即 src/)。所有用户数据(pattern_slots.json、自定义图片)都写/读自 BASE_DIR
@@ -80,17 +83,25 @@ DLPilot/
9. **EDID 来源是注册表**(`HKLM\SYSTEM\CurrentControlSet\Enum\DISPLAY\...\Device Parameters\EDID`),
不是 WMI/DDC;exe 的 noconsole 版无 stdout,`--dump/--edid-fields` 只在源码/控制台版可用。
10. **光机串口线程回调不直接碰 GUI**:DLPC485 的 on_event/on_status 在后台线程触发,
必须经 `queue.Queue` + `root.after(80, …)` 轮询投递回主线程(现行 `_poll_dlpc` 模式,
必须经 `queue.Queue` + Qt 定时器轮询投递回主线程(现行 `_poll_dlpc` 模式,
与 I2C-Inject 的 Injector 同源);退出路径(关窗与 `--seconds` 自动关)都会走到
`App.destroy()`,在那里 `dlpc.close()` 收线程。
`App.closeEvent()`,在那里 `dlpc.close()` 收线程。
11. **pattern_slots.json 格式**:`{'area', 'slots', 'serial'}` 三键共存;load 兼容旧版
纯数组(无 area)与无 serial 键两种历史格式,save 永远写全三键。`serial` 子键为
`{port, baud, bytesize(8/7/6/5), parity(N/E/O), stopbits(1/2)}`,旧配置缺子键按
默认 8-N-1 补齐。
12. **GUI = app_gui.py 双栏仪表盘**(规格:`DLPilot/UI参考布局.svg`):左列 HDMI 源端
五卡(显示器/模式/EDID 信息/测试图案/hex),右列光机四卡(串口/数据/命令/滑条),
顶栏 Dark/Light/System 切换(ttk.Treeview 颜色随动)。打包必须 `--collect-all
customtkinter`(build_exe.py 已内置,勿删)——CTk 主题/字体数据缺失会让 exe 启动即崩。
12. **GUI = app_fluent.py 双栏仪表盘**(规格:`DLPilot/UI参考布局.svg`):左列 HDMI 源端
五卡(显示器/模式/EDID 信息/测试图案/hex),右列光机五卡(串口/数据/电源/命令/滑条),
支持的模式和 EDID 信息默认折叠;顶栏 Dark/Light/System 切换。Light/Dark 同时更新
Fluent 主题、Qt 调色板、容器样式与 Windows DWM 标题栏,勿只调用 setTheme。
**fluent 控件的自定义样式禁止直接 setStyleSheet**:setTheme 会用库自带 qss 重刷全部
已注册控件,直写样式随之丢失(v0.4.0 swatch 丢底色的根因)。须走
`setCustomStyleSheet(控件, lightQss, darkQss)`(qss 需覆盖 hover/pressed/disabled 态,
否则悬停瞬间被自带态样式盖回);仅 PushButton 等无 themeChanged 钩子的控件可幸存。
FluentLabelBase 系(StrongBodyLabel 等)例外:其 setTextColor 在每次切主题时整体重写
自定义槽,任何 setCustomStyleSheet 都会被冲掉——字号等改用 QFont(自带 label.qss 无
font-size 规则,QFont 不会被冲)。RGB swatch 底色回归测试见 selftest_theme.py。
PD 协议成功显示绿色,FPDO 仍独立判定。打包必须收集 `qfluentwidgets` 资源,并加入 Windows 系统 `icuuc.dll`,否则 QtCore 可能被其它 ICU DLL 解析失败。
## 4. 领域语义(测试值都有含义,勿"顺手改")
@@ -106,18 +117,20 @@ DLPilot/
(DLPC 未就绪时 M730/M731/M732 被拒回 0xFF)以上游 `protocols.md` 为唯一权威,
本仓不维护协议文档副本;注意 **M7S0 是"开启"TEC 自动控温**(方向反直觉)、
M737 在定时投光中延迟应答(CLI 等待上限 35s)。
- **M303/M304 载荷是二进制例外**(SQ52205 电源实测):成功载荷 2 字节大端原始值,
- **M303/M304/M305 载荷是二进制例外**(SQ52205 电源实测):成功载荷为大端原始值,
**不是 ASCII**——Frame.payload 解出来是乱码,必须走 `payload_bytes()` 取原始字节;
M303=u16 mV(12V 轨窗口 11400~12600),M304=s16 补码 mA(正=正向,固件原样上总线,
上位机按有符号解析,FF 06=−250)。封装在 `payload_u16_be/payload_s16_be/format_power`,
回归向量在 selftest_rs485.py(真机抓取帧)。
上位机按有符号解析,FF 06=−250),**M305=u32 mW(LEN=4,勿复用双字节解析;
恒非负,量程 0~295000,能量方向看 M304 符号;显示 mW→W 两位小数)**。
封装在 `payload_u16_be/payload_s16_be/payload_u32_be/format_power`,
回归向量在 selftest_rs485.py(M303/M304 真机帧,M305 算法推演帧)。
- **电源自检序列**(M301 协议→M302 挡位最大电流→M303 实测母线电压,帧驱动状态机
`_pd_on_frame/_pd_seq`,超时 3s/步):FPDO 判定 = 电压就近挡(标准 PD 固定挡
5/9/12/15/20/28V,±5% 容差)+ M302 挡位电流;**12V 且 ≥3A 绿底,挡位不符红底,
无匹配挡 ERR**。应答按 TYPE=0xF0 判成功;
CH224Q 电压配置寄存器(0x0A)只写不可读,挡位只能实测判定。
- **功率检测** = M303+M304 → 读数框 电压V / 电流mA;**功率值暂不计算**
(用户要求:后续固件会提供功率命令直接返回,届时填充),显示占位"功率: —"。
- **功率检测** = M303+M304+M305 序列 → 读数框 电压V / 电流mA / 功率W(M305 u32 mW
换算两位小数);各步载荷长度按 kind 校验(2/2/4 字节),坏帧中止显示 ERR。
## 5. 构建 / 发布流水线(全部在 src/ 下执行)
@@ -126,14 +139,15 @@ python gen_bw_patterns.py ; python gen_solid_colors.py # 仅当改了图案生
python build_exe.py # → ../release/DLPilot.exe(缺 PyInstaller 会自动 pip install)
python build_exe.py --debug # 附加带控制台的 DLPilot-console.exe(调试 --dump 用)
python package_release.py <版本> # → ../release/DLPilot-v<版本>-win64.zip
python publish_gitea.py DLPilot-v<版本>-win64.zip --replace --insecure # 上传(需 GITEA_TOKEN)
python publish_gitea.py ../release/DLPilot-v<版本>-win64.zip --replace --insecure # 上传(需 GITEA_TOKEN)
```
发布约定:
- tag/release 命名 `DLPilot-vX.Y.Z`,zip 命名 `DLPilot-vX.Y.Z-win64.zip`(publish_gitea.py
依赖此命名从文件名推 tag);
- **exe、pattern_slots.json 与 release/ 下平铺的图案 png 改动跟随源码/资产生成提交入库;
zip 永不入库**(.gitignore 已挡);
- **pattern_slots.json 与 release/ 下平铺的图案 png 改动跟随源码/资产生成提交入库;
exe 不入库**(2026-10-13 起全仓 `.gitignore` 挡 `*.exe`,交付物只走 Gitea
Release 附件,本地保留);**zip 永不入库**(.gitignore 已挡);
- publish_gitea.py 幂等:release 已存在则复用,同名资产默认跳过,`--replace` 删旧重传;
- token 读环境变量 `GITEA_TOKEN`:本机已用 setx 持久化为用户级变量(新开的会话/终端自动
继承,值不入库;在 setx 之前启动的进程读不到,需重开终端),急用时也可临时传 `--token`;
@@ -151,17 +165,94 @@ python publish_gitea.py DLPilot-v<版本>-win64.zip --replace --insecure # 上
- GUI 与 hdmi_source.ps1 各自独立实现显示器枚举,行为可能微差(GUI 与 `-List` 序号一致,
但与"设置"面板序号无保证);多屏操作前先确认序号;
- `pattern_slots.json` 兼容两种历史格式,写出只写新格式(详见 §3.11);
- CustomTkinter 6.x:`CTkComboBox` 不支持 `textvariable`(用 `.set()/.get()`);`CTkTextbox`
的 `tag_config` 在部分版本不可用,app_gui 已 try/except 兜底为单色;同一容器内
grid/pack 不可混用(卡片用 grid 挂进列,卡片内部一律 pack——`_card` 返回 (卡片, 内容));
- `--seconds` 冒烟在已有 DLPilot 实例(含用户开着的 exe)运行时会"挂起":run_gui
检测到单实例互斥后弹模态框等点击。无头验证改用直启 `App(5)` 的等效脚本,或先关实例;
重打包同理——build_exe.py 会 taskkill 同名进程,等于替用户关掉开着的实例;
- 旧版 `app_gui.py` 仅保留作历史对照,当前入口使用 `app_fluent.py`;新增或修改界面时
应遵循 PySide6-Fluent-Widgets 的控件和 Qt 布局,不要把旧版 CustomTkinter 依赖带回入口。
## 7. 验证清单(改完代码后)
1. `python -m py_compile src/*.py` —— 全模块可编译;
1. `python -m compileall -q src`(在 DLPilot/ 下) —— 全模块可编译;
2. `python selftest_rs485.py` —— 光机协议回归(改 dlpc_rs485.py 后必跑,向量来自上游 protocols.md);
3. `python hdmi_gui.py --seconds 5` —— GUI 冒烟(自动开关);
4. `python hdmi_gui.py --test-pattern Checker --area 800x600 --seconds 3` —— 投图链路;
5. `python hdmi_gui.py --dump` —— EDID 解算不抛异常;
6. `python hdmi_gui.py --dlpc-cmd M999`(不带 --port)—— 应明确报错而非崩溃(无硬件时的优雅降级);
7. `python build_exe.py` → 双击 release/DLPilot.exe 确认图标/双栏布局/投图/光机区渲染;
8. `python package_release.py <版本>` —— 不报"引用图片缺失"即栏位完整性 OK。
3. `python selftest_theme.py` —— 主题切换回归(改 app_fluent.py 涉及样式/主题后必跑;
离屏渲染,Dark/Light 往返后校验 swatch 底色样式与渲染像素、标题字号);
3b. `python selftest_log.py` —— 收发日志上限/队列消费回归(改 `_dlpc_log`/`_poll_dlpc`
后必跑;600 行封顶 500、坏条目不中断 drain);
4. `python hdmi_gui.py --seconds 5` —— GUI 冒烟(自动开关);
5. `python hdmi_gui.py --test-pattern Checker --area 800x600 --seconds 3` —— 投图链路;
6. `python hdmi_gui.py --dump` —— EDID 解算不抛异常;
7. `python hdmi_gui.py --dlpc-cmd M999`(不带 --port)—— 应明确报错而非崩溃(无硬件时的优雅降级);
8. `python build_exe.py` → 双击 release/DLPilot.exe 确认图标/双栏布局/投图/光机区渲染;
9. `python package_release.py <版本>` —— 不报"引用图片缺失"即栏位完整性 OK。
## 8. v0.4.0 进度记录(2026-09-24)
- 入口已切换 `app_fluent.py`;历史 `app_gui.py` 保留,不改其历史版本号。
- 本次验证:源码编译通过、RS485 自测 ALL PASS、Qt 离屏浅色渲染、正式 EXE 定时退出码 0。
原生桌面自动化不可用,未完成成品 Light 截图;未将硬件功能标记为本次实机验证通过。
- 发布已将本次源码、文档、EXE 纳入同一个提交(`74b64dc`),远端标签 `DLPilot-v0.4.0`
指向该提交、附件已核对;zip 不入库。
- `release/pattern_slots.json` 的本机串口记忆改动属于运行态配置,勿随提交悄悄带入;
发布使用仓库预置配置,临时打包后恢复本机文件。
- 关联动态:EDID-Viewer 已于 2026-09-25 从本工具的"源端 EDID 查看"需求独立成工具
入库并发布 v0.1.0,与本目录无代码共享;I2C-Inject 已标记暂停开发(详见根 `agent.md`)。
## 9. v0.4.0 后修复记录
- 2026-09-28 修复"切换 Light 后 R/G/B hex 按钮底色丢失、切回 Dark 也不恢复":根因是
fluent 控件直接 `setStyleSheet` 的自定义样式在 `setTheme` 时被库重刷的自带 qss 整体
覆盖(任何方向的切换都会丢,非 Light 特有)。swatch 改走 `setCustomStyleSheet`(qss
覆盖 hover/pressed/disabled 态),同病的顶栏标题 24px 字号改用 QFont(FluentLabelBase
的自定义槽被库的 setTextColor 每次切主题重写,见 §3.12)。新增 `src/selftest_theme.py`
离屏回归(样式表存活 + 渲染像素比对),本次验证:compileall / selftest_rs485 /
selftest_theme / GUI 冒烟全部通过。
- 2026-09-28 以 v0.4.1 重新打包:APP_VERSION → 0.4.1,`build_exe.py` 生成新
release/DLPilot.exe(67.1 MB,exe `--seconds 5` 定时退出冒烟 exit 0),按 §8 约定
临时还原预置 pattern_slots.json 后 `package_release.py 0.4.1` 生成
`DLPilot-v0.4.1-win64.zip`(66.8 MB,zip 内为预置 COM15 配置,本机 COM5 运行态
已还原未入库)。Gitea Release 已发布:tag `DLPilot-v0.4.1` 指向提交 `62b325c`
(源码+exe+文档同一提交),资产 `DLPilot-v0.4.1-win64.zip`(66.8 MB),
https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases/tag/DLPilot-v0.4.1 。
发布说明用 --notes-file 传入(内置 DEFAULT_NOTES 模板还是 v0.1.0 旧内容,下次
发布记得先写说明文件)。
- 2026-09-28 第二修:"串口未选/选错点连接后窗口被拉长、刷新按钮够不到"。根因:
pyserial 原始异常串(`could not open port '': FileNotFoundError...`)整段塞进
状态 BodyLabel,QLabel 不换行时最小宽度=整行文本宽,把右列参数行最小宽度撑爆。
三层修复:①`_dlpc_toggle` 空串口直接短提示返回(不再先写 SERIAL_CFG/save_slots
再 open——旧代码这顺序还会把本机串口记忆覆写成空串,本机 COM5 记忆即这样丢失过,
已手工修回);②dlpc_rs485 open 失败消息分类为短提示(被占用/不存在/其它),
完整异常改走 on_event 进收发日志;③`lbl_dlpc_st.setWordWrap(True)` 兜底。
验证:compileall / selftest_rs485 ALL PASS / 离屏断言(空串口提示、长文本下标签
最小宽度<300 可换行)/ GUI 冒烟 / 新 exe `--seconds 5` exit 0;v0.4.1 exe(15:05)
与 zip 均已用该修复重打。
- 2026-09-28 v0.4.2 两处 UI 小修:①"串口数据 · 收发日志"卡的"清空"按钮原本自占
一行(_card 的标题行是独立 row,按钮行加在其下),与标题错位且多占一行高度——
`_card()` 增加 `trailing` 参数把按钮并入标题行右端;②v0.4.0 fluent 重写把旧版
app_gui.py 光机命令卡首行的快捷按钮排(开光机/关光机/查版本/软件重启/开TEC/
关TEC/TEC33.0°C)整行丢失,开/关光机只能走预设下拉——本次按旧版排布原样恢复,
位于电流行与预设行之间(命令直发 `_dlpc_send`,无新增协议逻辑)。
验证:compileall / selftest_rs485 / selftest_theme ALL PASS;离屏渲染断言"清空"
与标题垂直中心差 0px、7 个快捷按钮齐全;真实桌面冒烟 exit 0。APP_VERSION → 0.4.2
并重打包。v0.4.2 已发布:tag `DLPilot-v0.4.2` 指向提交 `7bd9943`(源码+exe+文档
同一提交),资产 `DLPilot-v0.4.2-win64.zip`(66.8 MB,zip 内为预置 COM15 配置,
本机 COM5 运行态已还原未入库),
https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases/tag/DLPilot-v0.4.2 。
本次发布说明已用 --notes-file 传入(临时文件,未入库)。
- 2026-10-13 v0.4.3 修复"长会话越跑越卡、命令点了没反应"(FilmForge 同案):
根因是 `_dlpc_log` 的手工 textCursor 裁剪用了**实例级枚举访问**
(`cursor.Start`/`cursor.BlockUnderCursor`),PySide6 6.11 移除该形式,收发日志
超 500 行后每次追加必抛 AttributeError;异常又逃出 `_poll_dlpc` 的 drain 循环
(旧 try 只捕获 queue.Empty),每 tick 最多消费到第一条 event,`_dlpc_log_q`
棘轮积压、日志裁剪/自动滚动全失效;电源自检/功率检测序列在 `_pd_step_next()`
之前的 `_dlpc_log` 调用处被打断,`_pd_seq` 永不清空,后续所有手动命令被
"序列进行中"拦截直到断开重连。整改:①改用 `setMaximumBlockCount(500)` 环形
裁剪(O(1),不依赖枚举写法);②`_poll_dlpc` 改逐事件兜底 try/except,单条异常
只打栈放弃该条、不再中断本轮消费;③新增 `selftest_log.py` 回归(600 行封顶 +
坏条目 drain 不中断)。验证:compileall / selftest_rs485 / selftest_theme /
selftest_log ALL PASS;GUI 冒烟 exit 0;`--dump` / `--dlpc-cmd`(无串口)正常。
APP_VERSION → 0.4.3 重打包(67.1 MB),按 §8 约定临时还原预置 pattern_slots.json
后 `package_release.py 0.4.3` 生成 `DLPilot-v0.4.3-win64.zip`(66.8 MB,zip 内
为预置 COM15 配置,本机 COM5 运行态已还原未入库)。遗留(未随本版处理):
`dlpc_rs485.py` 的 `except OSError` 把写超时(SerialTimeoutException)与设备
拔出混判,一次转换器停滞即断链误报"设备拔出"。
+12 -10
View File
@@ -4,11 +4,11 @@
> HDMI **源端**(EDID 查看、分辨率切换、纯色/测试图案投屏)+ **光机控制**
> (RS485 直连板上 GD32 网关,开关光机/设光强/查温度电流)。
>
> - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases)
> - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases)
> 下载 `DLPilot-vX.Y.Z-win64.zip`,解压双击 `DLPilot.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
> - **要改代码/重新打包**:见 §8、§9。
>
> 更新:2026-09-11
> 更新:2026-09-24
---
@@ -20,7 +20,7 @@
| EDID 逐字节解析 | "EDID 原始数据"框右上"逐字节解析"按钮:按偏移逐字段展开基础块与 CTA-861 扩展块,每字段附原始字节 hex;命令行等价 `--edid-fields` |
| 分辨率切换 | "分辨率"下拉列出显卡按该屏 EDID 提供的全部模式;**应用**=临时切换(不写注册表,重启/恢复即还原),**恢复**=回到本次会话切改前的模式 |
| 测试图案投屏 | 全屏投到所选显示器,测试卡区域可选 `640x360 / 800x600 / full 铺满`;内置 纯红/纯绿/纯蓝/黑白棋盘格/网格线 + R/G/B 三行 55/AA/FF 纯色值按钮 + 3 个自定义图片栏位 |
| 界面 | CustomTkinter 深色双栏仪表盘(左列 HDMI 源端 / 右列 光机控制),顶栏可切换 Dark/Light/System |
| 界面 | PySide6-Fluent-Widgets 双栏仪表盘(左列 HDMI 源端 / 右列 光机控制),顶栏可切换 Dark/Light/System |
| 光机控制 | RS485 → 板载 GD32E230 网关 → DLPC3421:开关光机(可定时)、设光强、查电流/温度/版本、TEC 控制、软件重启;光强/定时提供快捷滑条;收发日志带协议译码(见 §5) |
| 智能显示拓扑 | 仅当检测到转接板(EDID 厂商 "XLS")接入但未进入桌面(Windows 卡在"仅电脑屏幕")时才强制扩展把它拉回;你主动选的"仅第二屏幕"等拓扑不会被改动 |
| 命令行模式 | 全部功能可脚本化调用,供自动化测试(见 §6、§7) |
@@ -28,7 +28,7 @@
## 2. 下载与安装
1. 打开 [Releases 页面](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases),取最新的
1. 打开 [Releases 页面](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases),取最新的
`DLPilot-vX.Y.Z-win64.zip`;
2. 解压到**任意可写目录**(自定义图片与栏位配置会写到 exe 旁边);
3. 双击 `DLPilot.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。
@@ -104,7 +104,7 @@ GUI"光机控制"区:选串口 → 连接 → 点快捷按钮或命令下拉/
按实测电压就近判定 PD 固定挡(5/9/12/15/20/28V,容差 ±5%):**12V 挡且 ≥3A 绿底
"FPDO: 12V@3A"**;挡位不符(如 9V 挡)**红底**显示实际挡位;不落在任何标准挡显示
**ERR**(CH224Q 电压配置寄存器只写不可读,挡位只能靠实测判定)。**功率检测**依序发
M303+M304,读数框显示 电压(V) / 电流(mA);功率暂留空(后续固件命令直接返回)。
M303+M304+M305,读数框显示 电压(V) / 电流(mA) / 功率(W,mW 换算两位小数)。
| 命令 | 功能 | 响应示例 |
|---|---|---|
@@ -117,6 +117,7 @@ M303+M304,读数框显示 电压(V) / 电流(mA);功率暂留空(后续固
| `M302` | 查 PD 挡位最大电流(u16 大端 **mA**,50mA/LSB 固件已换算) | 0x0BB8 = 3000 mA = 3 A |
| `M303` | 查母线电压(SQ52205 实测,**2 字节大端二进制载荷**) | 0x2EF4 = 12020 mV = 12.02 V(12V 轨窗口 11400~12600) |
| `M304` | 查负载电流(SQ52205 实测,**2 字节大端二进制载荷**) | s16 补码 mA:0x0034=+52;FF 06=−250(正=正向) |
| `M305` | 查负载功率(SQ52205 实测,**4 字节大端二进制载荷** u32) | mW→显示 W 两位小数:0x00008CA0=36000 mW=36.00 W(量程 0~295000 mW) |
| `M999` | 查固件版本 | `okv20.0.0.1` |
| `M737` | 查投光是否结束(投光中延迟应答) | `ok` |
| `M7S0` / `M7S1` | TEC 自动控温开 / 关(注意 S0=开) | `ok` |
@@ -197,7 +198,8 @@ python la_wave_analyzer3.py <capture.csv> # 3 通道(RGB 三组同拍, ch8/
DLPilot/
├── src/ # 源代码(人工维护区)
│ ├── hdmi_gui.py # 入口与命令行模式(argparse 分发)
│ ├── app_gui.py # CustomTkinter 主界面(双栏仪表盘)
│ ├── app_fluent.py # PySide6-Fluent-Widgets 主界面(双栏仪表盘)
│ ├── app_gui.py # 历史 CustomTkinter 界面(保留,不由入口加载)
│ ├── display_win.py # Win32 显示器枚举/分辨率/智能拓扑(纯标准库)
│ ├── edid_parse.py # EDID 1.4/CEA-861 解析(纯标准库)
│ ├── patterns.py # 测试图案全屏投屏窗口
@@ -216,17 +218,17 @@ DLPilot/
│ ├── bw_*.png ×5 # 640x360 黑白系列
│ └── solid_*.png ×10 # 800x600 纯色系列
├── release/ # 构建与发布产物(机器生成区, 不手改)
│ ├── DLPilot.exe # 打包单文件版(改动跟随源码提交入库)
│ ├── DLPilot.exe # 打包单文件版(不入库,本地保留;随发布上传 Gitea Release)
│ ├── pattern_slots.json # exe 运行配置(栏位预置 + 串口记忆)
│ ├── *.png / README.md # 附属文件平铺副本(由 package_release.py 从 assets/ 同步)
│ └── *.zip # 发布包(不入库, 上传 Gitea Release)
├── README.md # 本说明
├── AGENTS.md # 面向 AI/Agent 的工程档案(结构/约定/构建规程)
└── UI参考布局.svg/.png # 界面布局设计稿(app_gui.py 按此实现)
└── UI参考布局.svg/.png # 界面布局设计稿(app_fluent.py 双栏布局参考)
```
从源码运行:`python src/hdmi_gui.py`,需 Python ≥3.10;EDID/命令行纯标准库;
GUI 需 customtkinter(`py -m pip install customtkinter`);光机控制需 pyserial;
GUI 需 PySide6-Fluent-Widgets(`py -m pip install PySide6-Fluent-Widgets`);光机控制需 pyserial;
自定义图片要 JPG/BMP/WebP 需可选 Pillow。
## 9. 构建与发布
@@ -235,7 +237,7 @@ GUI 需 customtkinter(`py -m pip install customtkinter`);光机控制需 p
cd src
python selftest_rs485.py # 光机协议回归(改 dlpc_rs485.py 后必跑)
python build_exe.py # 打包 → ../release/DLPilot.exe(自动装 PyInstaller;
# 内置 pyserial/customtkinter, CTk 主题数据自动收集)
# 内置 pyserial/PySide6-Fluent-Widgets, Fluent 主题资源自动收集)
python build_exe.py --debug # 额外出一个带控制台的调试变体(--dump 可见输出)
python package_release.py 0.2.0 # 组装 release/DLPilot-v0.2.0-win64.zip
python publish_gitea.py DLPilot-v0.2.0-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN)
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: 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

+11 -9
View File
@@ -4,11 +4,11 @@
> HDMI **源端**(EDID 查看、分辨率切换、纯色/测试图案投屏)+ **光机控制**
> (RS485 直连板上 GD32 网关,开关光机/设光强/查温度电流)。
>
> - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases)
> - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases)
> 下载 `DLPilot-vX.Y.Z-win64.zip`,解压双击 `DLPilot.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
> - **要改代码/重新打包**:见 §8、§9。
>
> 更新:2026-09-11
> 更新:2026-09-24
---
@@ -20,7 +20,7 @@
| EDID 逐字节解析 | "EDID 原始数据"框右上"逐字节解析"按钮:按偏移逐字段展开基础块与 CTA-861 扩展块,每字段附原始字节 hex;命令行等价 `--edid-fields` |
| 分辨率切换 | "分辨率"下拉列出显卡按该屏 EDID 提供的全部模式;**应用**=临时切换(不写注册表,重启/恢复即还原),**恢复**=回到本次会话切改前的模式 |
| 测试图案投屏 | 全屏投到所选显示器,测试卡区域可选 `640x360 / 800x600 / full 铺满`;内置 纯红/纯绿/纯蓝/黑白棋盘格/网格线 + R/G/B 三行 55/AA/FF 纯色值按钮 + 3 个自定义图片栏位 |
| 界面 | CustomTkinter 深色双栏仪表盘(左列 HDMI 源端 / 右列 光机控制),顶栏可切换 Dark/Light/System |
| 界面 | PySide6-Fluent-Widgets 双栏仪表盘(左列 HDMI 源端 / 右列 光机控制),顶栏可切换 Dark/Light/System |
| 光机控制 | RS485 → 板载 GD32E230 网关 → DLPC3421:开关光机(可定时)、设光强、查电流/温度/版本、TEC 控制、软件重启;光强/定时提供快捷滑条;收发日志带协议译码(见 §5) |
| 智能显示拓扑 | 仅当检测到转接板(EDID 厂商 "XLS")接入但未进入桌面(Windows 卡在"仅电脑屏幕")时才强制扩展把它拉回;你主动选的"仅第二屏幕"等拓扑不会被改动 |
| 命令行模式 | 全部功能可脚本化调用,供自动化测试(见 §6、§7) |
@@ -28,7 +28,7 @@
## 2. 下载与安装
1. 打开 [Releases 页面](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases),取最新的
1. 打开 [Releases 页面](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases),取最新的
`DLPilot-vX.Y.Z-win64.zip`;
2. 解压到**任意可写目录**(自定义图片与栏位配置会写到 exe 旁边);
3. 双击 `DLPilot.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。
@@ -104,7 +104,7 @@ GUI"光机控制"区:选串口 → 连接 → 点快捷按钮或命令下拉/
按实测电压就近判定 PD 固定挡(5/9/12/15/20/28V,容差 ±5%):**12V 挡且 ≥3A 绿底
"FPDO: 12V@3A"**;挡位不符(如 9V 挡)**红底**显示实际挡位;不落在任何标准挡显示
**ERR**(CH224Q 电压配置寄存器只写不可读,挡位只能靠实测判定)。**功率检测**依序发
M303+M304,读数框显示 电压(V) / 电流(mA);功率暂留空(后续固件命令直接返回)。
M303+M304+M305,读数框显示 电压(V) / 电流(mA) / 功率(W,mW 换算两位小数)。
| 命令 | 功能 | 响应示例 |
|---|---|---|
@@ -117,6 +117,7 @@ M303+M304,读数框显示 电压(V) / 电流(mA);功率暂留空(后续固
| `M302` | 查 PD 挡位最大电流(u16 大端 **mA**,50mA/LSB 固件已换算) | 0x0BB8 = 3000 mA = 3 A |
| `M303` | 查母线电压(SQ52205 实测,**2 字节大端二进制载荷**) | 0x2EF4 = 12020 mV = 12.02 V(12V 轨窗口 11400~12600) |
| `M304` | 查负载电流(SQ52205 实测,**2 字节大端二进制载荷**) | s16 补码 mA:0x0034=+52;FF 06=−250(正=正向) |
| `M305` | 查负载功率(SQ52205 实测,**4 字节大端二进制载荷** u32) | mW→显示 W 两位小数:0x00008CA0=36000 mW=36.00 W(量程 0~295000 mW) |
| `M999` | 查固件版本 | `okv20.0.0.1` |
| `M737` | 查投光是否结束(投光中延迟应答) | `ok` |
| `M7S0` / `M7S1` | TEC 自动控温开 / 关(注意 S0=开) | `ok` |
@@ -197,7 +198,8 @@ python la_wave_analyzer3.py <capture.csv> # 3 通道(RGB 三组同拍, ch8/
DLPilot/
├── src/ # 源代码(人工维护区)
│ ├── hdmi_gui.py # 入口与命令行模式(argparse 分发)
│ ├── app_gui.py # CustomTkinter 主界面(双栏仪表盘)
│ ├── app_fluent.py # PySide6-Fluent-Widgets 主界面(双栏仪表盘)
│ ├── app_gui.py # 历史 CustomTkinter 界面(保留,不由入口加载)
│ ├── display_win.py # Win32 显示器枚举/分辨率/智能拓扑(纯标准库)
│ ├── edid_parse.py # EDID 1.4/CEA-861 解析(纯标准库)
│ ├── patterns.py # 测试图案全屏投屏窗口
@@ -222,11 +224,11 @@ DLPilot/
│ └── *.zip # 发布包(不入库, 上传 Gitea Release)
├── README.md # 本说明
├── AGENTS.md # 面向 AI/Agent 的工程档案(结构/约定/构建规程)
└── UI参考布局.svg/.png # 界面布局设计稿(app_gui.py 按此实现)
└── UI参考布局.svg/.png # 界面布局设计稿(app_fluent.py 双栏布局参考)
```
从源码运行:`python src/hdmi_gui.py`,需 Python ≥3.10;EDID/命令行纯标准库;
GUI 需 customtkinter(`py -m pip install customtkinter`);光机控制需 pyserial;
GUI 需 PySide6-Fluent-Widgets(`py -m pip install PySide6-Fluent-Widgets`);光机控制需 pyserial;
自定义图片要 JPG/BMP/WebP 需可选 Pillow。
## 9. 构建与发布
@@ -235,7 +237,7 @@ GUI 需 customtkinter(`py -m pip install customtkinter`);光机控制需 p
cd src
python selftest_rs485.py # 光机协议回归(改 dlpc_rs485.py 后必跑)
python build_exe.py # 打包 → ../release/DLPilot.exe(自动装 PyInstaller;
# 内置 pyserial/customtkinter, CTk 主题数据自动收集)
# 内置 pyserial/PySide6-Fluent-Widgets, Fluent 主题资源自动收集)
python build_exe.py --debug # 额外出一个带控制台的调试变体(--dump 可见输出)
python package_release.py 0.2.0 # 组装 release/DLPilot-v0.2.0-win64.zip
python publish_gitea.py DLPilot-v0.2.0-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN)
+12 -12
View File
@@ -6,40 +6,40 @@
"file": "bw_star.png"
},
{
"name": "BW_ZONE",
"file": "bw_zoneplate.png"
"name": "black",
"file": "CeramixNano-black.png"
},
{
"name": "BW_TEST",
"file": "bw_testcard.png"
},
{
"name": "自定义4",
"file": null
"name": "calipoint",
"file": "CeramixNano-caliPoint.png"
},
{
"name": "自定义5",
"file": null
},
{
"name": "自定义6",
"file": null
"name": "circle_640P",
"file": "CeramixNano-circle_640p.png"
},
{
"name": "自定义7",
"file": null
},
{
"name": "自定义8",
"file": null
"name": "contrast",
"file": "CeramixNano-contrast.png"
},
{
"name": "自定义9",
"file": null
"name": "white",
"file": "CeramixNano-White.png"
},
{
"name": "自定义10",
"file": null
"name": "sharpness",
"file": "CeramixNano-Sharpness_XY_3.png"
}
],
"serial": {
+901
View File
@@ -0,0 +1,901 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""DLPilot Fluent UI.
The device, EDID and RS485 modules deliberately remain unchanged. This file
is the presentation layer for the current desktop application and uses
PySide6-Fluent-Widgets for the cards, controls and dialogs.
"""
import ctypes
import os
import queue
import re
import sys
import time
import traceback
# 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
# on Windows/Python 3.14 where the normal package import hook is unavailable.
if getattr(sys, "frozen", False) and os.name == "nt":
_frozen_dll_dirs = []
for _dll_dir in (os.path.join(getattr(sys, "_MEIPASS", ""), "PySide6"),
os.path.join(getattr(sys, "_MEIPASS", ""), "shiboken6")):
if os.path.isdir(_dll_dir):
_frozen_dll_dirs.append(_dll_dir)
try:
os.add_dll_directory(_dll_dir)
except (AttributeError, OSError):
pass
# Python 3.14 extension loading may ignore AddDllDirectory for a pyd's
# transitive dependencies; PATH keeps QtCore.pyd compatible with both
# Python 3.11-3.14 and PyInstaller one-file extraction.
if _frozen_dll_dirs:
os.environ["PATH"] = os.pathsep.join(_frozen_dll_dirs + [os.environ.get("PATH", "")])
# Preload the two libraries used by QtCore.pyd. This also covers Windows
# loader configurations that do not apply PATH/AddDllDirectory to a pyd's
# transitive dependencies after PyInstaller extraction.
try:
ctypes.WinDLL(os.path.join(getattr(sys, "_MEIPASS", ""), "shiboken6",
"shiboken6.abi3.dll"))
ctypes.WinDLL(os.path.join(getattr(sys, "_MEIPASS", ""), "PySide6",
"Qt6Core.dll"))
except (AttributeError, OSError):
pass
from PySide6.QtCore import QPoint, QRect, Qt, QTimer
from PySide6.QtGui import QColor, QFont, QIcon, QImage, QPainter, QPen, QPalette
from PySide6.QtWidgets import (
QApplication, QDialog, QFileDialog, QFormLayout, QGridLayout, QHBoxLayout,
QLabel, QMainWindow, QMessageBox, QPlainTextEdit, QScrollArea, QSplitter,
QToolButton, QVBoxLayout, QWidget,
)
from qfluentwidgets import (
BodyLabel, CardWidget, ComboBox, LineEdit, PlainTextEdit,
PrimaryPushButton, PushButton, Slider, StrongBodyLabel, Theme,
setCustomStyleSheet, setTheme,
)
import config
import display_win
import dlpc_rs485
import edid_parse
import patterns
APP_NAME = "DLPilot"
APP_VERSION = "0.4.3"
REPO_URL = "https://gitea.hulk.wang/hulk/HW-Debug-Tools"
MONO = QFont("Consolas", 10)
CHIP_IDLE = "#666666"
CHIP_OK = "#2FA574"
CHIP_ERR = "#D9534F"
_QT_PATTERNS = set()
def close_qt_patterns():
for window in list(_QT_PATTERNS):
window.close()
_QT_PATTERNS.clear()
patterns.close_all()
def _area_box(spec, width, height):
if spec == "full":
return 0, 0, width, height
try:
aw, ah = (int(v) for v in spec.lower().split("x", 1))
except Exception:
aw, ah = config.PAT_W, config.PAT_H
return (width - aw) // 2, (height - ah) // 2, aw, ah
class PatternWindow(QWidget):
"""Borderless Qt equivalent of the old tkinter test-pattern window."""
def __init__(self, rect, kind, area, image_path=None, parent=None):
super().__init__(parent)
self.kind = kind
self.area = area
self.image = QImage(image_path) if image_path else None
x, y, width, height = rect
self.setGeometry(x, y, width, height)
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint |
Qt.Tool)
self.setAttribute(Qt.WA_DeleteOnClose)
_QT_PATTERNS.add(self)
self.show()
self.activateWindow()
self.setFocus()
def keyPressEvent(self, event):
if event.key() == Qt.Key_Escape:
self.close()
else:
super().keyPressEvent(event)
def closeEvent(self, event):
_QT_PATTERNS.discard(self)
super().closeEvent(event)
def paintEvent(self, _event):
painter = QPainter(self)
painter.fillRect(self.rect(), Qt.black)
x0, y0, aw, ah = _area_box(self.area, self.width(), self.height())
if self.image and not self.image.isNull():
painter.drawImage(QPoint(x0 + (aw - self.image.width()) // 2,
y0 + (ah - self.image.height()) // 2),
self.image)
return
if self.kind.startswith("#"):
painter.fillRect(x0, y0, aw, ah, QColor(self.kind))
elif self.kind in {"Red", "Green", "Blue"}:
painter.fillRect(x0, y0, aw, ah,
QColor({"Red": "#ff0000", "Green": "#00ff00",
"Blue": "#0000ff"}[self.kind]))
elif self.kind == "Checker":
for gy in range((ah + 39) // 40):
for gx in range((aw + 39) // 40):
if (gx + gy) % 2 == 0:
painter.fillRect(x0 + gx * 40, y0 + gy * 40, 40, 40,
Qt.white)
elif self.kind == "Grid":
painter.setPen(QPen(Qt.white, 1))
for x in range(0, aw + 1, 20):
painter.drawLine(x0 + x, y0, x0 + x, y0 + ah)
for y in range(0, ah + 1, 20):
painter.drawLine(x0, y0 + y, x0 + aw, y0 + y)
def _single_instance():
if os.name != "nt":
return True
try:
ctypes.windll.kernel32.CreateMutexW(None, False, "hulk.DLPilot.single")
return ctypes.windll.kernel32.GetLastError() != 183
except Exception:
return True
class App(QMainWindow):
def __init__(self, auto_close=0):
super().__init__()
app = QApplication.instance()
self._system_palette = app.palette() if app else QPalette()
self._system_style = app.styleSheet() if app else ""
self.setWindowTitle(f"{APP_NAME} v{APP_VERSION} — 3D 光固化光机调试台")
icon_path = self._icon_path()
if icon_path:
self.setWindowIcon(QIcon(icon_path))
self.resize(1440, 960)
self.setMinimumSize(1180, 760)
self.displays = []
self._disp_labels = []
self._modes = {}
self._orig_modes = {}
self._cur_label = ""
self.slots, self.area = config.load_slots()
self._dlpc_log_q = queue.Queue()
self.dlpc = dlpc_rs485.DLPC485(self._dlpc_on_event, self._dlpc_on_status,
on_frame=self._dlpc_on_frame)
self._dlpc_connected = False
self._tl_order = []
self._pd_seq = []
self._pd_values = {}
self._pd_timer = None
self._pwr_seq = []
self._pwr_values = {}
self._pwr_timer = None
self._build()
self._load_displays()
self._timer = QTimer(self)
self._timer.timeout.connect(self._poll_dlpc)
if dlpc_rs485.HAS_SERIAL:
self._timer.start(80)
if auto_close > 0:
QTimer.singleShot(auto_close * 1000, self.close)
@staticmethod
def _icon_path():
base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
path = os.path.join(base, "logo_dlpilot.ico")
if os.path.exists(path):
return path
path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"logos", "logo_dlpilot.ico")
return path if os.path.exists(path) else None
def _button(self, text, callback, primary=False):
button = PrimaryPushButton(text) if primary else PushButton(text)
button.clicked.connect(callback)
return button
def _card(self, title, parent, trailing=None):
card = CardWidget(parent)
layout = QVBoxLayout(card)
layout.setContentsMargins(14, 12, 14, 14)
layout.setSpacing(8)
row = QHBoxLayout()
row.addWidget(StrongBodyLabel(title, card))
row.addStretch(1)
if trailing is not None:
row.addWidget(trailing)
layout.addLayout(row)
return card, layout
def _collapsible_card(self, title, parent, expanded=False):
"""Card whose body starts collapsed to keep the dashboard compact."""
card = CardWidget(parent)
layout = QVBoxLayout(card)
layout.setContentsMargins(8, 6, 8, 8)
layout.setSpacing(4)
header = QToolButton(card)
header.setText(title)
header.setCheckable(True)
header.setChecked(expanded)
header.setAutoRaise(True)
header.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
header.setArrowType(Qt.DownArrow if expanded else Qt.RightArrow)
header.setStyleSheet("QToolButton { text-align: left; font-weight: 700; padding: 6px; }")
layout.addWidget(header)
body = QWidget(card)
body_layout = QVBoxLayout(body)
body_layout.setContentsMargins(6, 0, 6, 4)
body_layout.setSpacing(8)
body.setVisible(expanded)
layout.addWidget(body)
def toggle(checked):
body.setVisible(checked)
header.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
card.adjustSize()
header.clicked.connect(toggle)
return card, body_layout
def _text(self, read_only=True, mono=True):
text = PlainTextEdit(self)
text.setReadOnly(read_only)
if mono:
text.setFont(MONO)
text.setLineWrapMode(QPlainTextEdit.NoWrap)
return text
def _build(self):
root = QWidget(self)
root.setObjectName("root")
outer = QVBoxLayout(root)
outer.setContentsMargins(18, 14, 18, 18)
outer.setSpacing(12)
header = QHBoxLayout()
title = StrongBodyLabel("DLPilot", root)
# 标题字号走 QFont:FluentLabelBase 在每次主题切换时会用自带的颜色规则
# 重写自定义 qss 槽,setStyleSheet/setCustomStyleSheet 写的字号都会被冲掉
title_font = title.font()
title_font.setPixelSize(24)
title_font.setBold(True)
title.setFont(title_font)
header.addWidget(title)
header.addWidget(BodyLabel("EDID · 测试图案 · RS485 光机控制", root))
header.addStretch(1)
self.theme = ComboBox(root)
self.theme.addItems(["Dark", "Light", "System"])
self.theme.setCurrentText("System")
self.theme.currentTextChanged.connect(self._set_theme)
header.addWidget(self.theme)
header.addWidget(self._button("关于", self._show_about))
outer.addLayout(header)
split = QSplitter(Qt.Horizontal, root)
split.setChildrenCollapsible(False)
split.addWidget(self._scroll_column(self._build_left))
split.addWidget(self._scroll_column(self._build_right))
split.setStretchFactor(0, 1)
split.setStretchFactor(1, 1)
outer.addWidget(split, 1)
self.setCentralWidget(root)
def _scroll_column(self, builder):
host = QWidget()
layout = QVBoxLayout(host)
layout.setContentsMargins(2, 2, 8, 2)
layout.setSpacing(10)
builder(layout)
layout.addStretch(1)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
scroll.setWidget(host)
if builder.__name__ == '_build_left':
self.left_scroll = scroll
else:
self.right_scroll = scroll
return scroll
def _build_left(self, layout):
card, body = self._card("显示器选择", self)
row = QHBoxLayout()
self.cbo_display = ComboBox(card)
self.cbo_display.currentTextChanged.connect(self._on_select)
self.lbl_cur = BodyLabel("当前输出: —", card)
row.addWidget(self.cbo_display, 1)
row.addWidget(self._button("刷新", self._load_displays))
body.addLayout(row)
body.addWidget(self.lbl_cur)
layout.addWidget(card)
card, body = self._collapsible_card("支持的模式(EDID 解算)", self)
row = QHBoxLayout()
self.cbo_mode = ComboBox(card)
row.addWidget(BodyLabel("分辨率:", card))
row.addWidget(self.cbo_mode, 1)
row.addWidget(self._button("应用", self._apply_mode, True))
row.addWidget(self._button("恢复", self._restore_mode))
body.addLayout(row)
self.txt_modes = self._text()
self.txt_modes.setMinimumHeight(135)
body.addWidget(self.txt_modes)
layout.addWidget(card)
card, body = self._collapsible_card("EDID 信息", self)
self.txt_edid = self._text()
self.txt_edid.setMinimumHeight(150)
body.addWidget(self.txt_edid)
layout.addWidget(card)
card, body = self._card("测试图案", self)
row = QHBoxLayout()
for key, name in patterns.PATTERN_NAMES.items():
row.addWidget(self._button(name, lambda checked=False, k=key: self._show_pattern(k)))
row.addWidget(self._button("停止", close_qt_patterns))
body.addLayout(row)
area_row = QHBoxLayout()
area_row.addWidget(BodyLabel("测试卡区域:", card))
self.cbo_area = ComboBox(card)
self.cbo_area.addItems(list(config.AREA_OPTIONS))
self.cbo_area.setCurrentText(self.area)
self.cbo_area.currentTextChanged.connect(self._save_area)
area_row.addWidget(self.cbo_area)
area_row.addStretch(1)
body.addLayout(area_row)
for channel, items in patterns.SOLID_ROWS:
row = QHBoxLayout()
row.addWidget(BodyLabel(f"{channel}: 0x", card))
for label, color in items:
button = PushButton(label, card)
# fluent 控件的样式须走 setCustomStyleSheet:直接 setStyleSheet 的内容
# 会在 setTheme 时被库重刷的自带 qss 冲掉;且须覆盖 hover/pressed 态,
# 否则悬停瞬间底色仍会被自带态样式盖回
fg = 'black' if color == '#00FF00' else 'white'
qss = ("PushButton, PushButton:hover, PushButton:pressed, "
f"PushButton:disabled {{ background-color: {color}; color: {fg}; border: none; }}")
setCustomStyleSheet(button, qss, qss)
button.clicked.connect(lambda checked=False, c=color: self._show_solid(c))
row.addWidget(button)
row.addStretch(1)
body.addLayout(row)
self.slot_buttons = []
grid = QGridLayout()
for i in range(config.SLOT_COUNT):
cell = QWidget(card)
cell_layout = QHBoxLayout(cell)
cell_layout.setContentsMargins(0, 0, 0, 0)
button = self._button(self.slots[i]["name"], lambda checked=False, n=i: self._show_custom(n))
gear = self._button("⚙", lambda checked=False, n=i: self._configure_slot(n))
cell_layout.addWidget(button, 1)
cell_layout.addWidget(gear)
grid.addWidget(cell, i // 2, i % 2)
self.slot_buttons.append(button)
body.addLayout(grid)
layout.addWidget(card)
card, body = self._card("EDID 原始数据", self)
self.txt_hex = self._text()
self.txt_hex.setMinimumHeight(130)
body.addWidget(self.txt_hex)
body.addWidget(self._button("逐字节解析", self._show_edid_fields))
layout.addWidget(card)
def _build_right(self, layout):
if not dlpc_rs485.HAS_SERIAL:
card, body = self._card("光机控制不可用", self)
body.addWidget(BodyLabel("未安装 pyserial,请执行 py -m pip install pyserial。"))
layout.addWidget(card)
return
card, body = self._card("串口 · RS485 → GD32 网关 → DLPC3421", self)
row = QHBoxLayout()
self._dlpc_ports = dlpc_rs485.DLPC485.list_ports_info()
self.cbo_dlpc = ComboBox(card)
self.cbo_dlpc.addItems([label for _device, label in self._dlpc_ports])
if config.SERIAL_CFG["port"]:
self._dlpc_pick(config.SERIAL_CFG["port"])
row.addWidget(self.cbo_dlpc, 1)
row.addWidget(self._button("刷新", self._dlpc_refresh))
self.btn_dlpc = self._button("连接", self._dlpc_toggle, True)
row.addWidget(self.btn_dlpc)
body.addLayout(row)
params = QHBoxLayout()
for caption, attr, values, default in (
("波特率", "cbo_baud", ["115200", "57600", "38400", "9600"], str(config.SERIAL_CFG["baud"])),
("数据位", "cbo_databits", ["8", "7", "6", "5"], str(config.SERIAL_CFG["bytesize"])),
("校验", "cbo_parity", ["N", "E", "O"], str(config.SERIAL_CFG["parity"])),
("停止位", "cbo_stopbits", ["1", "2"], str(config.SERIAL_CFG["stopbits"])),
):
params.addWidget(BodyLabel(caption, card))
combo = ComboBox(card)
combo.addItems(values)
combo.setCurrentText(default)
setattr(self, attr, combo)
params.addWidget(combo)
self.lbl_dlpc_st = BodyLabel("未连接", card)
# 状态标签允许换行兜底: 任何长错误串都不得撑大参数行最小宽度(否则窗口拉长,
# 刷新/连接按钮被挤出可视区)
self.lbl_dlpc_st.setWordWrap(True)
params.addWidget(self.lbl_dlpc_st)
params.addStretch(1)
body.addLayout(params)
layout.addWidget(card)
card, body = self._card("串口数据 · 收发日志(译码 + 原始 hex)", self,
trailing=self._button("清空", self._dlpc_clear))
self.txt_dlpc = self._text()
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)
self._dlpc_log("帧格式: D5 01 LEN <命令> CRC / B5 TYPE LEN <payload> CRC")
layout.addWidget(card)
card, body = self._card("电源检查 · PD 自检 / 功率读数", self)
self.chip_pd = QLabel("PD", card)
self.chip_fpdo = QLabel("FPDO: —", card)
self.var_pwr = "电压: — 电流: — 功率: —"
self.box_pwr = QLabel(self.var_pwr, card)
for widget in (self.chip_pd, self.chip_fpdo, self.box_pwr):
widget.setMinimumHeight(30)
widget.setAlignment(Qt.AlignCenter | Qt.AlignVCenter)
widget.setStyleSheet(f"background: {CHIP_IDLE}; color: white; border-radius: 6px; padding: 4px 8px;")
row = QHBoxLayout()
row.addWidget(self._button("电源自检", self._pd_selftest))
row.addWidget(self.chip_pd)
row.addWidget(self.chip_fpdo, 1)
body.addLayout(row)
row = QHBoxLayout()
row.addWidget(self._button("功率检测", self._pwr_query))
row.addWidget(self.box_pwr, 1)
body.addLayout(row)
layout.addWidget(card)
card, body = self._card("光机命令 · 预设 / 自定义 / 温度", self)
row = QHBoxLayout()
self.var_current = BodyLabel("电流: —", card)
row.addWidget(self.var_current)
row.addStretch(1)
row.addWidget(self._button("查电流", self._query_current))
body.addLayout(row)
# 快捷命令行: v0.4.0 重写时从旧版 app_gui.py 丢失, 开关光机等一发命令
# 不应只能走预设下拉
quick = QHBoxLayout()
for name, cmd in (("开光机", "M730S0"), ("关光机", "M730S1"), ("查版本", "M999"),
("软件重启", "M888"), ("开TEC", "M7S0"), ("关TEC", "M7S1"),
("TEC33.0°C", "M8S330")):
quick.addWidget(self._button(name, lambda checked=False, c=cmd: self._dlpc_send(c)))
body.addLayout(quick)
preset_row = QHBoxLayout()
self._dlpc_presets = [(n, cmd) for n, cmd, _ in dlpc_rs485.PRESETS]
self.cbo_dlpc_cmd = ComboBox(card)
self.cbo_dlpc_cmd.addItems([f"{n} {cmd}" for n, cmd in self._dlpc_presets])
preset_row.addWidget(BodyLabel("预设:", card))
preset_row.addWidget(self.cbo_dlpc_cmd, 1)
preset_row.addWidget(self._button("发送", self._dlpc_send_preset, True))
preset_row.addWidget(self._button("查 DMD 温度", self._query_dmd))
self.var_dmd = BodyLabel("DMD: —", card)
preset_row.addWidget(self.var_dmd)
body.addLayout(preset_row)
custom_row = QHBoxLayout()
self.var_custom_cmd = LineEdit(card)
self.var_custom_cmd.setPlaceholderText("如 M730S0T2000")
self.var_custom_cmd.returnPressed.connect(self._dlpc_send_custom)
custom_row.addWidget(BodyLabel("自定义:", card))
custom_row.addWidget(self.var_custom_cmd, 1)
custom_row.addWidget(self._button("发送", self._dlpc_send_custom))
custom_row.addWidget(self._button("查 LAMP 温度", self._query_lamp))
self.var_lamp = BodyLabel("LAMP: —", card)
custom_row.addWidget(self.var_lamp)
body.addLayout(custom_row)
layout.addWidget(card)
card, body = self._card("快捷滑条 · 光强 / 定时投光 / TEC 温度", self)
self.sld_light, self.ent_light = self._slider_row(body, "光强 (54-800)", 54, 800, 100, self._dlpc_send_light, "下发电流值")
self.sld_timer, self.ent_timer = self._slider_row(body, "定时投光 (毫秒)", 0, 32700, 2000, self._dlpc_send_timer, "开光机(定时)")
self.sld_tec, self.ent_tec = self._slider_row(body, "TEC 凝露点 (0.1°C)", 200, 350, 330, self._dlpc_send_tec, "下发温度值")
layout.addWidget(card)
def _slider_row(self, layout, text, low, high, value, callback, button_text):
row = QHBoxLayout()
row.addWidget(BodyLabel(text, self))
slider = Slider(Qt.Horizontal, self)
slider.setRange(low, high)
slider.setValue(value)
entry = LineEdit(self)
entry.setText(str(value))
entry.setFixedWidth(65)
slider.valueChanged.connect(lambda v: entry.setText(str(v)))
entry.returnPressed.connect(lambda: self._sync_slider(slider, entry, low, high))
row.addWidget(slider, 1)
row.addWidget(entry)
row.addWidget(self._button(button_text, callback))
layout.addLayout(row)
return slider, entry
@staticmethod
def _sync_slider(slider, entry, low, high):
try:
value = int(entry.text())
except ValueError:
value = slider.value()
value = max(low, min(high, value))
slider.setValue(value)
entry.setText(str(value))
def _set_theme(self, value):
app = QApplication.instance()
setTheme({"Dark": Theme.DARK, "Light": Theme.LIGHT, "System": Theme.AUTO}[value])
if value == "System":
app.setPalette(self._system_palette)
app.setStyleSheet(self._system_style)
is_dark = app.palette().color(QPalette.Window).value() < 128
else:
is_dark = value == "Dark"
self._apply_palette(is_dark)
self._apply_native_titlebar(is_dark)
def _apply_palette(self, is_dark):
"""Keep native Qt widgets and Fluent cards on the same surface colors."""
app = QApplication.instance()
palette = QPalette()
if is_dark:
window, base, text, border = "#202020", "#2b2b2b", "#f5f5f5", "#3a3a3a"
accent = "#00c8d7"
else:
window, base, text, border = "#f4f6f8", "#ffffff", "#1f2328", "#d8dee4"
accent = "#0f6cbd"
for role in (QPalette.Window, QPalette.Base, QPalette.AlternateBase):
palette.setColor(role, QColor(window if role == QPalette.Window else base))
palette.setColor(QPalette.Text, QColor(text))
palette.setColor(QPalette.WindowText, QColor(text))
palette.setColor(QPalette.Button, QColor(base))
palette.setColor(QPalette.ButtonText, QColor(text))
palette.setColor(QPalette.PlaceholderText, QColor("#8b949e" if not is_dark else "#8a8a8a"))
palette.setColor(QPalette.Highlight, QColor(accent))
palette.setColor(QPalette.HighlightedText, QColor("#ffffff"))
palette.setColor(QPalette.ToolTipBase, QColor(base))
palette.setColor(QPalette.ToolTipText, QColor(text))
app.setPalette(palette)
app.setStyleSheet(f"""
QMainWindow, QWidget#root {{ background-color: {window}; color: {text}; }}
QScrollArea, QScrollArea > QWidget > QWidget {{ background: transparent; border: 0; }}
CardWidget {{ background-color: {base}; border: 1px solid {border}; border-radius: 8px; }}
QPlainTextEdit, QTextEdit, QLineEdit, QComboBox {{
background-color: {base}; color: {text};
border: 1px solid {border}; border-radius: 5px;
selection-background-color: {accent};
}}
QToolButton {{ color: {text}; }}
""")
def _apply_native_titlebar(self, is_dark):
if os.name != "nt" or not hasattr(ctypes, "windll"):
return
try:
hwnd = int(self.winId())
dwm = ctypes.windll.dwmapi
dark = ctypes.c_int(1 if is_dark else 0)
dwm.DwmSetWindowAttribute(hwnd, 20, ctypes.byref(dark), ctypes.sizeof(dark))
color = QColor("#202020" if is_dark else "#f4f6f8")
colorref = ctypes.c_int(color.red() | (color.green() << 8) | (color.blue() << 16))
text = QColor("#f5f5f5" if is_dark else "#1f2328")
textref = ctypes.c_int(text.red() | (text.green() << 8) | (text.blue() << 16))
dwm.DwmSetWindowAttribute(hwnd, 35, ctypes.byref(colorref), ctypes.sizeof(colorref))
dwm.DwmSetWindowAttribute(hwnd, 36, ctypes.byref(textref), ctypes.sizeof(textref))
except Exception:
pass
def _cur_display(self):
try:
return self.displays[self._disp_labels.index(self.cbo_display.currentText())]
except (ValueError, IndexError, AttributeError):
return None
def _load_displays(self):
previous = self._cur_label
try:
self.displays = display_win.get_displays()
except Exception as exc:
self.displays = []
self.notice("显示器枚举失败", str(exc), error=True)
self._disp_labels = [f"{d['device']} {d['adapter']} [{d['mon_name']}]" for d in self.displays]
self.cbo_display.blockSignals(True)
self.cbo_display.clear()
self.cbo_display.addItems(self._disp_labels)
if self._disp_labels:
self.cbo_display.setCurrentText(previous if previous in self._disp_labels else self._disp_labels[0])
self.cbo_display.blockSignals(False)
if self._disp_labels:
self._cur_label = self.cbo_display.currentText()
self._on_select(self._cur_label)
self.left_scroll.verticalScrollBar().setValue(0)
self.right_scroll.verticalScrollBar().setValue(0)
else:
self.lbl_cur.setText("未找到活动显示器")
self.txt_modes.clear(); self.txt_edid.clear(); self.txt_hex.clear()
def _on_select(self, _value=None):
d = self._cur_display()
if d is None:
return
self._cur_label = self.cbo_display.currentText()
self.lbl_cur.setText(f"当前输出: {d['cur_mode']}")
keys, self._modes = display_win.enum_modes(d['device'])
self.cbo_mode.clear(); self.cbo_mode.addItems(keys)
if d['cur_mode'] in self._modes:
self.cbo_mode.setCurrentText(d['cur_mode'])
if not d['edid']:
self.txt_edid.setPlainText("EDID 读取失败(注册表无 EDID 数据)")
self.txt_modes.clear(); self.txt_hex.clear(); return
parsed = edid_parse.parse_edid(d['edid'])
self.txt_edid.setPlainText("\n".join(f"{k:<16}{v}" for k, v in edid_parse.edid_info_lines(parsed)))
lines = [f"{t['hact']}x{t['vact']} @{t['refresh']:g}Hz {t['clk_khz'] / 1000:.2f}MHz {'DTD 首选' if i == 0 else 'DTD'}" for i, t in enumerate(parsed['dtds'])]
lines += [f"{m} [CEA-VIC]" for m in parsed['cea_video']]
lines += [f"{m} [已建立时序]" for m in parsed['est']]
lines += [f"{m} [标准时序]" for m in parsed['std']]
self.txt_modes.setPlainText("\n".join(lines))
self.txt_hex.setPlainText(edid_parse.hex_dump(d['edid']))
def _apply_mode(self):
d = self._cur_display()
if d is None or self.cbo_mode.currentText() not in self._modes:
return
device = d['device']
if device not in self._orig_modes:
current = display_win.DEVMODEW(); current.dmSize = ctypes.sizeof(current)
if display_win.user32.EnumDisplaySettingsW(device, display_win.ENUM_CURRENT_SETTINGS, ctypes.byref(current)):
self._orig_modes[device] = current
ok, result = display_win.apply_mode(device, self._modes[self.cbo_mode.currentText()])
if not ok:
self.notice("切换失败", f"ChangeDisplaySettingsEx 返回码 {result}", error=True)
return
QTimer.singleShot(500, self._load_displays)
def _restore_mode(self):
d = self._cur_display()
if d is None: return
mode = self._orig_modes.pop(d['device'], None)
if mode is None:
self.notice("无可恢复", "该显示器没有记录切改前的模式")
return
display_win.apply_mode(d['device'], mode)
QTimer.singleShot(500, self._load_displays)
def _show_pattern(self, kind):
d = self._cur_display()
if d and d['rect']:
close_qt_patterns(); PatternWindow(d['rect'], kind, self.cbo_area.currentText(), parent=self)
def _show_solid(self, color):
self._show_pattern(color)
def _save_area(self, value):
self.area = value
config.save_slots(self.slots, value)
def _show_custom(self, index):
slot = self.slots[index]
if not slot['file']:
self.notice("未配置图片", f"请先配置“{slot['name']}”的图片")
return
path = os.path.join(config.BASE_DIR, slot['file'])
d = self._cur_display()
if not os.path.exists(path):
self.notice("图片不存在", path, error=True); return
if d and d['rect']:
close_qt_patterns(); PatternWindow(d['rect'], "", self.cbo_area.currentText(), path, self)
def _configure_slot(self, index):
dialog = QDialog(self); dialog.setWindowTitle(f"配置自定义栏位 {index + 1}")
form = QFormLayout(dialog)
name = LineEdit(dialog); name.setText(self.slots[index]['name'])
file_label = BodyLabel(self.slots[index]['file'] or "(未设置)", dialog)
form.addRow("按钮名称", name); form.addRow("图片文件", file_label)
buttons = QHBoxLayout(); choose = PushButton("选择图片...", dialog); clear = PushButton("清除图片", dialog); save = PrimaryPushButton("保存", dialog); cancel = PushButton("取消", dialog)
buttons.addWidget(choose); buttons.addWidget(clear); buttons.addStretch(1); buttons.addWidget(save); buttons.addWidget(cancel); form.addRow(buttons)
selected = [self.slots[index]['file']]
def choose_file():
path, _ = QFileDialog.getOpenFileName(dialog, "选择图片", config.BASE_DIR, f"图片文件 ({' '.join('*' + e for e in config.IMG_EXTS)})")
if path:
try: selected[0] = config.copy_image_into_script_dir(path); file_label.setText(selected[0])
except OSError as exc: self.notice("复制图片失败", str(exc), error=True)
choose.clicked.connect(choose_file); clear.clicked.connect(lambda: (selected.__setitem__(0, None), file_label.setText("(未设置)")))
cancel.clicked.connect(dialog.reject)
def save_slot():
self.slots[index] = {'name': name.text().strip() or f'自定义{index + 1}', 'file': selected[0]}
config.save_slots(self.slots, self.cbo_area.currentText()); self.slot_buttons[index].setText(self.slots[index]['name']); dialog.accept()
save.clicked.connect(save_slot); dialog.exec()
def _show_about(self):
QMessageBox.about(self, f"关于 {APP_NAME}", f"{APP_NAME} v{APP_VERSION}\n\nEDID / 测试图案 / RS485 光机控制\nPySide6-Fluent-Widgets UI\n\n{REPO_URL}")
def _show_edid_fields(self):
d = self._cur_display()
if d is None or not d['edid']:
self.notice("无 EDID", "该显示器没有可解析的 EDID 数据"); return
dialog = QDialog(self); dialog.setWindowTitle("EDID 逐字节解析"); dialog.resize(1120, 700)
layout = QVBoxLayout(dialog); text = self._text(); text.setLineWrapMode(QPlainTextEdit.WidgetWidth)
lines = []
for off, hx, meaning, value, note in edid_parse.edid_field_rows(d['edid']):
lines.append(f"{off} {meaning} {value}")
if hx: lines.append(f" 原始: {hx}")
if note: lines.append(f" {note}")
text.setPlainText("\n".join(lines)); layout.addWidget(text); layout.addWidget(self._button("关闭", dialog.accept)); dialog.exec()
# ---------------- RS485 ----------------
def _dlpc_on_frame(self, frame):
if self._pd_seq: self._dlpc_log_q.put(('pdframe', frame))
elif self._pwr_seq: self._dlpc_log_q.put(('pwrframe', frame))
def _dlpc_on_event(self, line):
match = re.search(r'← okTL:(-?\d+)', line)
if match and self._tl_order:
which = self._tl_order.pop(0); self._dlpc_log_q.put(('temp', f"{which}: {int(match.group(1)) / 10:.1f}°C"))
match = re.search(r'← okR(\d+)G(\d+)B(\d+)', line)
if match: self._dlpc_log_q.put(('current', f"电流 R:{match.group(1)} G:{match.group(2)} B:{match.group(3)}"))
self._dlpc_log_q.put(('event', line))
def _dlpc_on_status(self, message): self._dlpc_log_q.put(('status', message))
def _query_current(self): self._dlpc_send('M732')
def _query_dmd(self): self._tl_order.append('DMD'); self._dlpc_send('M201')
def _query_lamp(self): self._tl_order.append('LAMP'); self._dlpc_send('M202')
def _poll_dlpc(self):
while True:
try:
kind, value = self._dlpc_log_q.get_nowait()
except queue.Empty:
break
try:
if kind == 'status':
self.lbl_dlpc_st.setText(value)
if value.startswith(('已断开', '串口丢失')): self._serial_reset_state()
elif kind == 'temp': (self.var_dmd if value.startswith('DMD') else self.var_lamp).setText(value)
elif kind == 'current': self.var_current.setText(value)
elif kind == 'pdframe': self._pd_step_done(value.type_code == 0xF0, value)
elif kind == 'pwrframe': self._pwr_step_done(value)
else: self._dlpc_log(value)
except Exception:
# 单条事件处理异常只打栈放弃该条, 不得中断本轮消费——否则积压随事件流
# 单调增长(v0.4.3 前日志裁剪异常即此路径, 显示延迟越跑越大)
traceback.print_exc()
connected = self.dlpc.is_connected()
if connected != self._dlpc_connected:
self._dlpc_connected = connected; self.btn_dlpc.setText("断开" if connected else "连接")
def _dlpc_log(self, line):
self.txt_dlpc.appendPlainText(time.strftime('%H:%M:%S ') + line)
self.txt_dlpc.verticalScrollBar().setValue(self.txt_dlpc.verticalScrollBar().maximum())
def _dlpc_clear(self): self.txt_dlpc.clear()
def _dlpc_refresh(self):
self._dlpc_ports = dlpc_rs485.DLPC485.list_ports_info(); self.cbo_dlpc.clear(); self.cbo_dlpc.addItems([label for _d, label in self._dlpc_ports])
if self._dlpc_ports: self._dlpc_pick(config.SERIAL_CFG['port'] or self._dlpc_ports[0][0])
def _dlpc_pick(self, device):
for dev, label in self._dlpc_ports:
if dev == device: self.cbo_dlpc.setCurrentText(label); return
def _dlpc_selected_port(self):
label = self.cbo_dlpc.currentText()
for device, shown in self._dlpc_ports:
if shown == label: return device
return label.strip()
def _dlpc_toggle(self):
if self.dlpc.is_connected(): self.dlpc.close(); self._serial_reset_state(); return
port = self._dlpc_selected_port()
if not port: self.lbl_dlpc_st.setText('未选择串口,请先选择或刷新'); return
try: baud, bytesize, stopbits = int(self.cbo_baud.currentText()), int(self.cbo_databits.currentText()), int(self.cbo_stopbits.currentText())
except ValueError: self.lbl_dlpc_st.setText("串口参数非法"); return
parity = self.cbo_parity.currentText()[:1] or 'N'; config.SERIAL_CFG.update(port=port, baud=baud, bytesize=bytesize, parity=parity, stopbits=stopbits); config.save_slots(self.slots, self.cbo_area.currentText()); self.dlpc.open(port, baud, bytesize=bytesize, parity=parity, stopbits=stopbits)
def _serial_reset_state(self):
self._pd_seq.clear(); self._pwr_seq.clear(); self._tl_order.clear()
for timer in (self._pd_timer, self._pwr_timer):
if timer: timer.stop()
self._pd_timer = self._pwr_timer = None
def _dlpc_send(self, command):
if not self.dlpc.is_connected(): self.lbl_dlpc_st.setText('未连接串口'); return
if self._pd_seq or self._pwr_seq: self._dlpc_log(f'序列进行中, 命令已忽略: {command}'); return
self.dlpc.send(command)
def _dlpc_send_preset(self):
label = self.cbo_dlpc_cmd.currentText()
for name, command in self._dlpc_presets:
if label.startswith(name + ' '): self._dlpc_send(command); return
def _dlpc_send_custom(self):
if self.var_custom_cmd.text().strip(): self._dlpc_send(self.var_custom_cmd.text().strip())
def _pd_selftest(self):
if not self.dlpc.is_connected(): self.lbl_dlpc_st.setText('未连接串口'); return
if self._pd_seq: return
self._pd_seq = [('M301', 'pd'), ('M302', 'fpdo_ma'), ('M303', 'meas_mv')]; self._pd_values = {}; self._pd_chip_reset(); self._dlpc_log('电源自检: 开始'); self._pd_step_next()
def _pd_chip_reset(self):
self.chip_pd.setText('PD'); self.chip_pd.setStyleSheet(f'background: {CHIP_IDLE}; color: white; border-radius: 6px; padding: 4px 8px;'); self.chip_fpdo.setText('FPDO: —'); self.chip_fpdo.setStyleSheet(f'background: {CHIP_IDLE}; color: white; border-radius: 6px; padding: 4px 8px;')
def _pd_step_next(self):
if not self._pd_seq: self._pd_fpdo_verdict(); self._dlpc_log('电源自检: 完成'); return
self.dlpc.send(self._pd_seq[0][0]); self._pd_timer = QTimer(self); self._pd_timer.setSingleShot(True); self._pd_timer.timeout.connect(lambda: self._pd_step_done(False, None)); self._pd_timer.start(3000)
def _pd_step_done(self, ok, frame):
if not self._pd_seq: return
if self._pd_timer: self._pd_timer.stop(); self._pd_timer = None
_command, kind = self._pd_seq.pop(0); raw = dlpc_rs485.payload_bytes(frame) if frame is not None else b''
if ok and kind == 'pd':
proto = raw.decode('ascii', 'replace').strip()
good = proto in ('PD', 'EPR')
self.chip_pd.setText(proto or 'PD')
self.chip_pd.setStyleSheet(
f'background: {CHIP_OK if good else CHIP_ERR}; color: white; '
'border-radius: 6px; padding: 4px 8px;')
elif ok and kind == 'fpdo_ma' and len(raw) == 2: self._pd_values['ma'] = dlpc_rs485.payload_u16_be(raw)
elif ok and kind == 'meas_mv' and len(raw) == 2: self._pd_values['mv'] = dlpc_rs485.payload_u16_be(raw)
if ok: self._pd_step_next()
else: self._pd_seq.clear(); self._pd_chip_error(kind); self._dlpc_log('电源自检: 应答错误/超时, 序列中止')
def _pd_chip_error(self, kind):
widget = self.chip_pd if kind == 'pd' else self.chip_fpdo
if kind == 'pd': widget.setText('ERR')
else: widget.setText('FPDO: ERR')
widget.setStyleSheet(f'background: {CHIP_ERR}; color: white; border-radius: 6px; padding: 4px 8px;')
def _pd_fpdo_verdict(self):
mv, ma = self._pd_values.get('mv'), self._pd_values.get('ma')
if mv is None or ma is None: self._pd_chip_error('fpdo_ma'); return
gear = next((g for g in (5, 9, 12, 15, 20, 28) if abs(mv - g * 1000) <= g * 50), None); text = 'FPDO: ERR' if gear is None else f'FPDO: {gear}V@{ma / 1000:g}A'; self.chip_fpdo.setText(text); good = gear == 12 and ma >= 3000; self.chip_fpdo.setStyleSheet(f'background: {CHIP_OK if good else CHIP_ERR}; color: white; border-radius: 6px; padding: 4px 8px;')
def _pwr_query(self):
if not self.dlpc.is_connected(): self.lbl_dlpc_st.setText('未连接串口'); return
if self._pwr_seq: return
self._pwr_seq = [('M303', 'mv'), ('M304', 'ma'), ('M305', 'mw')]; self._pwr_values = {}; self._dlpc_log('功率检测: 开始'); self._pwr_step_next()
def _pwr_step_next(self):
if not self._pwr_seq: self._pwr_update(self._pwr_values.get('mv'), self._pwr_values.get('ma'), self._pwr_values.get('mw')); self._dlpc_log('功率检测: 完成'); return
self.dlpc.send(self._pwr_seq[0][0]); self._pwr_timer = QTimer(self); self._pwr_timer.setSingleShot(True); self._pwr_timer.timeout.connect(lambda: self._pwr_step_done(None)); self._pwr_timer.start(3000)
def _pwr_step_done(self, frame):
if not self._pwr_seq: return
if self._pwr_timer: self._pwr_timer.stop(); self._pwr_timer = None
_command, kind = self._pwr_seq.pop(0); raw = dlpc_rs485.payload_bytes(frame) if frame is not None else b''; want = {'mv': 2, 'ma': 2, 'mw': 4}[kind]
if frame is None or frame.type_code != 0xF0 or len(raw) != want: self._pwr_seq.clear(); self.box_pwr.setText('电压: ERR 电流: — 功率: —'); return
self._pwr_values[kind] = {'mv': dlpc_rs485.payload_u16_be, 'ma': dlpc_rs485.payload_s16_be, 'mw': dlpc_rs485.payload_u32_be}[kind](raw); self._pwr_step_next()
def _pwr_update(self, mv, ma, mw):
voltage = f'{mv / 1000:.2f} V' if mv is not None else '—'
current = f'{ma} mA' if ma is not None else '—'
power = f'{mw / 1000:.2f} W' if mw is not None else '—'
self.box_pwr.setText(f'电压: {voltage} 电流: {current} 功率: {power}')
def _dlpc_send_light(self): self._sync_slider(self.sld_light, self.ent_light, 54, 800); self._dlpc_send(f'M731S{self.sld_light.value()}')
def _dlpc_send_timer(self): self._sync_slider(self.sld_timer, self.ent_timer, 0, 32700); self._dlpc_send('M730S0' if self.sld_timer.value() <= 0 else f'M730S0T{self.sld_timer.value()}')
def _dlpc_send_tec(self): self._sync_slider(self.sld_tec, self.ent_tec, 200, 350); self._dlpc_send(f'M8S{self.sld_tec.value()}')
def notice(self, title, text, error=False):
box = QMessageBox(self); box.setWindowTitle(title); box.setText(text); box.setIcon(QMessageBox.Critical if error else QMessageBox.Information); box.exec()
def closeEvent(self, event):
close_qt_patterns()
self._serial_reset_state()
try: self.dlpc.close()
except Exception: pass
super().closeEvent(event)
def run_gui(auto_close=0):
if not _single_instance():
app = QApplication.instance() or QApplication(sys.argv)
QMessageBox.information(None, "DLPilot 已在运行", "检测到另一个 DLPilot 实例正在运行。")
return
app = QApplication.instance() or QApplication(sys.argv)
app.setApplicationName(APP_NAME)
setTheme(Theme.AUTO)
window = App(auto_close)
window.show()
app.exec()
+18 -11
View File
@@ -40,8 +40,8 @@ CHIP_OK = '#2FA574'
CHIP_ERR = '#D9534F'
APP_NAME = 'DLPilot'
APP_VERSION = '0.3.5'
REPO_URL = 'https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools'
APP_VERSION = '0.3.6'
REPO_URL = 'https://gitea.hulk.wang/hulk/HW-Debug-Tools'
def get_icon_path():
@@ -997,22 +997,22 @@ class App(ctk.CTk):
self.var_fpdo.set(f'FPDO: {gear}V@{a_txt}A')
def _pwr_query(self):
"""功率检测: M303 实测电压 + M304 实测电流(按有符号); 功率值待固件功率命令"""
"""功率检测: M303 电压 + M304 电流(按有符号) + M305 功率(u32 mW)"""
if not self.dlpc.is_connected():
self.lbl_dlpc_st.configure(text='未连接串口', text_color='#E07A3F')
return
if self._pwr_seq:
return
self._pwr_seq = [('M303', 'mv'), ('M304', 'ma')]
self._pwr_seq = [('M303', 'mv'), ('M304', 'ma'), ('M305', 'mw')]
self._pwr_values = {}
self._dlpc_log('功率检测: 开始')
self._pwr_step_next()
def _pwr_step_next(self):
if not self._pwr_seq:
v_mv = self._pwr_values.get('mv', 0)
ma = self._pwr_values.get('ma', 0)
self._pwr_update(v_mv, ma)
self._pwr_update(self._pwr_values.get('mv'),
self._pwr_values.get('ma'),
self._pwr_values.get('mw'))
self._dlpc_log('功率检测: 完成')
return
self.dlpc.send(self._pwr_seq[0][0])
@@ -1025,8 +1025,10 @@ class App(ctk.CTk):
self.after_cancel(self._pwr_timer)
self._pwr_timer = None
cmd, kind = self._pwr_seq.pop(0)
# 各步载荷长度: 电压/电流 2 字节, 功率 4 字节(u32)
want_len = {'mv': 2, 'ma': 2, 'mw': 4}[kind]
raw = dlpc_rs485.payload_bytes(fr) if fr is not None else b''
if fr is None or fr.type_code != 0xF0 or len(raw) != 2:
if fr is None or fr.type_code != 0xF0 or len(raw) != want_len:
self._pwr_seq.clear()
self.var_pwr.set('电压: ERR 电流: — 功率: —')
self._dlpc_log('功率检测: 应答错误/超时, 中止')
@@ -1035,11 +1037,16 @@ class App(ctk.CTk):
self._pwr_values['mv'] = dlpc_rs485.payload_u16_be(raw)
elif kind == 'ma':
self._pwr_values['ma'] = dlpc_rs485.payload_s16_be(raw)
elif kind == 'mw':
self._pwr_values['mw'] = dlpc_rs485.payload_u32_be(raw)
self._pwr_step_next()
def _pwr_update(self, v_mv, ma):
"""功率读数框更新: 电压 V / 电流 mA; 功率暂不计算(待固件功率命令返回后填充)"""
self.var_pwr.set(f'电压: {v_mv / 1000:.2f} V 电流: {ma} mA 功率: —')
def _pwr_update(self, v_mv, ma, mw):
"""功率读数框更新: 电压 V / 电流 mA / 功率 W(mW 换算, 两位小数; 缺项显示 —)"""
v = f'{v_mv / 1000:.2f} V' if v_mv is not None else '—'
a = f'{ma} mA' if ma is not None else '—'
w = f'{mw / 1000:.2f} W' if mw is not None else '—'
self.var_pwr.set(f'电压: {v} 电流: {a} 功率: {w}')
def _light_entry_apply(self, _evt=None):
"""滑条数值输入框回车: 校验并限幅 54-800, 同步回滑条"""
+16 -7
View File
@@ -3,8 +3,8 @@
"""
把 hdmi_gui.py 打包成单文件 exe(工具名: DLPilot)
================================
使用 PyInstaller --onefile --noconsole: Python 解释器 + 标准库 + tkinter
全部打进一个 exe, 目标机器无需安装任何依赖。
使用 PyInstaller --onefile --noconsole: Python 解释器、PySide6 和
PySide6-Fluent-Widgets 全部打进一个 exe, 目标机器无需安装依赖。
用法:
python build_exe.py 自动安装 PyInstaller(如缺)并打包
@@ -35,13 +35,22 @@ def ensure_pyinstaller():
def extra_collect_args():
"""CustomTkinter 的主题/字体等数据文件必须显式收集, 否则打包后 GUI 启动报错"""
"""Fluent Widgets 的主题资源必须收集; PyInstaller 自带 Qt hooks 会处理 PySide6。"""
try:
import customtkinter # noqa: F401
return ['--collect-all', 'customtkinter']
import qfluentwidgets # noqa: F401
except ImportError:
print('警告: 未安装 customtkinter, 打包产物将无法启动 GUI!')
print('警告: 未安装 PySide6-Fluent-Widgets, 请先执行 pip install PySide6-Fluent-Widgets')
return []
return ['--collect-all', 'qfluentwidgets']
def qt_icu_args():
"""Qt6Core imports the unversioned Windows ICU API. PyInstaller can
otherwise pick Poppler's ICU 78 from PATH, whose suffixed exports make
QtCore.pyd fail with WinError 127 on startup."""
if os.name == 'nt' and os.path.exists(r'C:\Windows\System32\icuuc.dll'):
return ['--add-binary', r'C:\Windows\System32\icuuc.dll;.']
return []
def remove_stale(exe_path):
@@ -68,7 +77,7 @@ def build(name, extra_args):
cmd = [sys.executable, '-m', 'PyInstaller',
'--onefile', '--clean', '--name', name,
'--distpath', dist, '--workpath', work, '--specpath', work,
] + extra_args + extra_collect_args() + [os.path.join(SRC, 'hdmi_gui.py')]
] + extra_args + extra_collect_args() + qt_icu_args() + [os.path.join(SRC, 'hdmi_gui.py')]
if os.path.exists(ICO):
# exe 文件图标 + 运行时窗口图标资源(从 _MEIPASS 读取)
cmd += ['--icon', ICO, '--add-data', f'{ICO};.']
+47 -14
View File
@@ -8,7 +8,7 @@ DLPC3421 光机 RS485 控制链路(板上 GD32E230 作 RS485→I2C 网关)
物理层 RS485 半双工 115200 8N1(PC 经 USB-RS485 转换器接板, DE 由 MCU 硬件换向)
命令帧 D5 01 LEN <ASCII命令> CRC 01=板类型, LEN=命令字节数
响应帧 B5 TYPE LEN <payload> CRC payload 为 ASCII(ok / err:OL / okTL:246 ...),
例外: M303/M304 载荷为 2 字节大端二进制
例外: M303/M304/M305 载荷为大端二进制
(SQ52205 电源实测, 见 POWER_COMMANDS)
CRC 帧内第 2 字节(板类型)起累加到最后一个 payload 字节, 取低 8 位
帧长 最小 6 字节, 最大 32 字节
@@ -83,12 +83,14 @@ PRESETS = (
Frame = collections.namedtuple('Frame', 'type_code payload raw')
# --- M303/M304(SQ52205 电源实测值查询): 成功载荷为 2 字节原始二进制(非 ASCII) ---
# --- M303/M304/M305(SQ52205 电源实测值查询): 成功载荷为原始二进制(非 ASCII) ---
# M303 母线电压: u16 大端, 单位 mV; 12V 轨正常窗口 11400~12600(标称 ±5%)
# M304 负载电流: s16 大端补码, 单位 mA; 正=正向, 负=反向(固件原样上总线,
# 上位机必须按有符号解析)。例: 载荷 FF 06 = -250 mA
# M305 负载功率: u32 大端(LEN=4, 勿复用双字节解析), 单位 mW, 恒非负;
# 量程约 0~295000 mW; 能量方向看 M304 符号
# 错误时 TYPE=0xFF, 载荷为错误信息字节串(走通用译码路径)
POWER_COMMANDS = ('M303', 'M304')
POWER_COMMANDS = ('M303', 'M304', 'M305')
def payload_bytes(fr):
@@ -105,11 +107,20 @@ def payload_s16_be(raw):
return v - 0x10000 if v & 0x8000 else v
def payload_u32_be(raw):
return (raw[0] << 24) | (raw[1] << 16) | (raw[2] << 8) | raw[3]
def format_power(cmd, fr):
"""M303/M304 成功应答 -> 人读文本; 非 POWER 命令/失败/载荷异常返回 None"""
"""电源实测命令成功应答 -> 人读文本; 非电源命令/失败/载荷异常返回 None"""
if cmd not in POWER_COMMANDS:
return None
raw = payload_bytes(fr)
if cmd == 'M305':
if fr.type_code != 0xF0 or len(raw) != 4:
return None
mw = payload_u32_be(raw)
return f'{mw} mW ({mw / 1000:.2f} W)'
if fr.type_code != 0xF0 or len(raw) != 2:
return None
if cmd == 'M303':
@@ -174,15 +185,29 @@ class FrameParser:
del self._buf[:1]
def _clean(s):
"""控制字符 -> '·': 二进制载荷按 ASCII 解出的 NUL 等会污染日志行,
且 Windows 剪贴板遇 NUL 直接截断复制内容"""
return ''.join(c if ord(c) >= 0x20 and ord(c) != 0x7F else '·' for c in s)
def describe(fr):
"""响应帧 -> 人读文本: TYPE 译码 + payload, err:N 追加 DLPC 错误释义"""
"""响应帧 -> 人读文本: TYPE 译码 + payload, err:N 追加 DLPC 错误释义。
二进制载荷(M303/M304/M305 电源实测)不以 ASCII 展示 —— 改显 hex,
避免乱码与 NUL 截断剪贴板(值由 GUI 读数框/CLI format_power 译码)。"""
pl = fr.payload
if any(ord(c) < 0x20 or ord(c) == 0x7F for c in pl):
pl = f'[二进制载荷 {fr.raw[3:-1].hex(" ").upper()}]'
else:
pl = _clean(pl)
if fr.type_code == 0xF0:
text = fr.payload or 'ok'
text = pl or 'ok'
else:
text = TYPE_NAMES.get(fr.type_code, f'TYPE=0x{fr.type_code:02X}')
if fr.payload:
text += f' {fr.payload}'
m = re.search(r'err:(\d+)', fr.payload)
if pl:
text += f' {pl}'
m = re.search(r'err:(\d+)', pl)
if m:
text += f'({DLPC_ERR_NAMES.get(int(m.group(1)), "未知错误码")})'
return text
@@ -301,11 +326,19 @@ class DLPC485:
self.ser = None
with self._lock:
self._target = None
et = str(e)
if 'PermissionError' in repr(e) or '拒绝访问' in et \
or 'Access is denied' in et:
et += '(串口被占用: 检查串口助手/另一个本工具实例)'
self.on_status(f'连接失败: {et}')
blob = str(e) + repr(e)
if 'PermissionError' in blob or '拒绝访问' in blob \
or 'Access is denied' in blob:
hint = '串口被占用(串口助手/另一实例?)'
elif 'FileNotFoundError' in blob or '找不到指定的' in blob \
or 'No such file' in blob:
hint = '串口不存在,请刷新后重选'
else:
hint = '无法打开,详情见日志'
# 状态标签只放短提示(长异常串会撑爆参数行把窗口拉长),
# 完整异常走 on_event 进收发日志
self.on_status(f'连接失败: {hint}')
self.on_event(f'连接失败: {e!r}')
if self.ser is not None:
try:
item = None
+6 -5
View File
@@ -6,7 +6,7 @@ HDMI EDID 查看器 + 测试图案投屏 + 光机控制 — 入口与命令行
配合 LT8619C(HDMI 转 RGB888)→ DLPC3421 转接板调试使用。
模块划分(重构自原单文件):
app_gui.py CustomTkinter 主界面(双栏仪表盘)
app_fluent.py PySide6-Fluent-Widgets 主界面(双栏仪表盘)
display_win.py Win32 显示器枚举/分辨率/拓扑
edid_parse.py EDID 1.4 / CEA-861 解析
patterns.py 测试图案全屏投屏窗口
@@ -45,9 +45,9 @@ from patterns import PATTERN_NAMES, show_pattern, show_pattern_image
def run_gui(auto_close=0):
"""启动 CustomTkinter 主界面(实现在 app_gui.py)"""
import app_gui
app_gui.run_gui(auto_close)
"""启动 PySide6-Fluent-Widgets 主界面(实现在 app_fluent.py)"""
import app_fluent
app_fluent.run_gui(auto_close)
def run_dump(edid_fields=False):
@@ -121,7 +121,8 @@ def run_dlpc(port, baud, cmds):
print('← (35s 超时无响应)')
rc = 1
else:
print(f'← {dlpc_rs485.describe(got)} [{got.raw.hex(" ").upper()}]')
nice = dlpc_rs485.format_power(cmd, got) # 电源命令二进制载荷译码
print(f'← {nice or dlpc_rs485.describe(got)} [{got.raw.hex(" ").upper()}]')
if got.type_code != 0xF0:
rc = 1
ser.close()
+2 -1
View File
@@ -31,7 +31,8 @@ BASE = os.path.dirname(SRC)
RELEASE = os.path.join(BASE, 'release')
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():
+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())
+16
View File
@@ -87,5 +87,21 @@ check('M304 应答帧', len(frames), 1)
check('M304 电流(0x0034=+52mA)', d.format_power('M304', frames[0]), '+52 mA')
check('M304 负值(FF06=-250mA 补码)', d.payload_s16_be(bytes.fromhex('FF 06')), -250)
# 9) M305(SQ52205 实测功率)回归向量: u32 大端 mW(LEN=4, 算法推演帧)
check('build_frame(M305)', d.build_frame('M305').hex(' ').upper(),
'D5 01 04 4D 33 30 35 EA')
frames = d.FrameParser().feed(bytes.fromhex('B5 F0 04 00 00 8C A0 20'))
check('M305 应答帧', len(frames), 1)
check('M305 功率(0x8CA0=36000mW)', d.format_power('M305', frames[0]),
'36000 mW (36.00 W)')
check('M305 拒绝双字节载荷', d.format_power(
'M305', d.FrameParser().feed(bytes.fromhex('B5 F0 02 8C A0 1E'))[0]), None)
# 10) 二进制载荷的日志行安全: 无控制字符(NUL 会截断 Windows 剪贴板), 以 hex 展示
frames = d.FrameParser().feed(bytes.fromhex('B5 F0 02 00 34 26')) # M304 应答(含 NUL)
desc = d.describe(frames[0])
check('二进制载荷 hex 展示', desc, '[二进制载荷 00 34]')
check('日志行无控制字符', any(ord(c) < 0x20 for c in desc), False)
print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS')
sys.exit(1 if fails else 0)
+82
View File
@@ -0,0 +1,82 @@
# coding: utf-8
"""主题切换回归自检(无窗口, 离屏渲染)。
背景: fluent 控件直接 setStyleSheet 的样式会在 setTheme 时被库重刷的自带 qss 冲掉
(v0.4.0 RGB swatch 按钮切主题丢底色的根因)。swatch 改走 setCustomStyleSheet、
标题字号走 QFont 后,本测试保证:
1. Dark -> Light -> Dark -> Light 往返后 9 个 swatch 按钮样式表仍带底色;
2. Light 下实际渲染出的按钮中心像素 = swatch 色值(不只样式表字符串存在);
3. 标题 24px 加粗(QFont)在往返后不变。
运行: python selftest_theme.py (依赖 PySide6-Fluent-Widgets)
"""
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QApplication
from qfluentwidgets import PushButton, StrongBodyLabel, Theme, setTheme
import app_fluent
import patterns
def check_style_sheet(win, app):
swatches = [b for b in win.findChildren(PushButton) if b.text() in {"55", "AA", "FF"}]
assert len(swatches) == 9, f"expect 9 swatch buttons, got {len(swatches)}"
title = [l for l in win.findChildren(StrongBodyLabel) if l.text() == "DLPilot"]
assert len(title) == 1, "title label not found"
colored = sum(1 for b in swatches if "background-color: #" in b.styleSheet())
f = title[0].font()
print(f" swatch 样式表带底色: {colored}/9, 标题 24px 粗体: "
f"{f.pixelSize() == 24 and f.bold()}")
return colored == 9 and f.pixelSize() == 24 and f.bold()
def check_rendered_pixel(win, app):
"""Light 主题下逐个渲染 swatch,与目标色一致的像素须占明显多数(背景为主,
中心处是文字字形,不能只采单点)。"""
ok = True
for channel, items in patterns.SOLID_ROWS:
for label, color in items:
button = next(b for b in win.findChildren(PushButton)
if b.text() == label and f"background-color: {color}" in b.styleSheet())
image = button.grab().toImage()
target = QColor(color)
total, matched = image.width() * image.height(), 0
for y in range(image.height()):
for x in range(image.width()):
if image.pixelColor(x, y) == target:
matched += 1
ratio = matched / total
same = ratio >= 0.2
ok &= same
if not same:
print(f" 渲染不符: {channel}/{label} 期望 {color} 仅占 {ratio:.0%}")
print(f" Light 下渲染像素比对: {'一致' if ok else '存在偏差'}")
return ok
def main():
app = QApplication.instance() or QApplication(sys.argv)
setTheme(Theme.AUTO)
win = app_fluent.App(0)
passed = True
for step, theme in (("初始 Dark", Theme.DARK), ("切 Light", Theme.LIGHT),
("切回 Dark", Theme.DARK), ("再切 Light", Theme.LIGHT)):
setTheme(theme)
app.processEvents()
print(f"[{step}]")
passed &= check_style_sheet(win, app)
if theme == Theme.LIGHT:
passed &= check_rendered_pixel(win, app)
win.close()
print("ALL PASS" if passed else "FAILED")
return 0 if passed else 1
if __name__ == "__main__":
sys.exit(main())
+8
View File
@@ -0,0 +1,8 @@
# 本机虚拟环境(根 .gitignore 已覆盖 build/ dist/ *.spec __pycache__/)
.venv/
# 构建日志(测试结果由 unittest 实时输出, 不留档)
build-log*.txt
# 验证脚本生成的截图
validation/*.png
+53
View File
@@ -0,0 +1,53 @@
# EDID Viewer — 开发需求与进度
## 基本环境
- 主项目位置:`E:\Hulk-Coding\HW-Debug-Tools\EDID-viewer`;为兼容此前约定,已同步镜像到 `E:\Hulk-Coding\LT8619C\_Debug\_Tools\EDID-viewer`。
- Windows 10/11 x64;Python 3.14;PySide6 6.11.2(Qt 官方 Python 绑定);PySide6-Fluent-Widgets 1.11.3。
- 使用项目 `.venv`;依赖见 requirements.txt;GUI 入口为 `src/main.py`(版本号在 `src/main.py` 的 `APP_VERSION`)。
- 独立工具,不依赖、不修改 LT8619C 固件或 DLPilot 工程。
## 工程结构与流水线(对齐 DLPilot 三区约定)
- 三区目录:`src/` 人写代码(main / edid / windows_edid / theme / test_edid + 打包流水线);`assets/` 图标与生成脚本(可再生);`release/` 交付(`EDID-Viewer.exe` 入库 + README)。
- 打包单文件 exe:`python src/build_exe.py`(spec 由脚本生成到 `src/build/`,不入库;原 viewer.spec 的 ICU 排除逻辑已内置其中)。`--debug` 附带带控制台变体。
- 发布:`python src/package_release.py <版本>` 组装 `release/EDID-Viewer-v*-win64.zip`(zip 不入库,只上传 Gitea Release);上传用 `python src/publish_gitea.py <zip>`,token 走环境变量 `GITEA_TOKEN`。
- 解析测试:`cd src && python -m unittest`(9 项,改 edid.py 后必跑)。
## 任务目标
1. 枚举 Windows 活动显示设备并读取 EDID 原始块。
2. 查看厂家代码、产品编号、名称、序列号、生产日期、分辨率和详细时序。
3. 展示 CTA 扩展、HDMI、音频、HDR、色彩能力和校验结果。
4. 支持离线 BIN/HEX 导入、BIN 和 JSON 报告导出。
5. Light / Dark / 跟随系统主题,持久化用户选择并响应系统主题变化。
6. 图标参考 DLPilot 黑底、橙色标签、白字,使用 EDID / Viewer 独立标识。
- Fluent 主题包含 `dark1`(蓝色)和 `dark2`(青色),两者均为显式深色模式;选中态文字必须保持高对比度。
- Fluent Widgets 版本已采用顶部操作区、下方信息卡片和分组页签;后续布局调整需保持信息层级清晰。
## Fluent Widgets 版本
- 界面使用 PySide6-Fluent-Widgets 的 `CardWidget`、`ComboBox`、`PrimaryPushButton`、`PlainTextEdit` 和 `TreeWidget`;顶部操作区、下方信息卡片和分组页签。
- Fluent 主题只负责表现层;EDID 解析、Windows WMI 读取、导入导出和线程边界不改变。
- 主题切换必须三件套同步(与 DLPilot `app_fluent.py` 同一方案):`setTheme` 之外,QPalette + 应用级 QSS 统一 QMainWindow 背景、原生 QTabWidget/QStatusBar 表面色,DWM 属性 20/35/36 切换原生标题栏深浅。只调 `setTheme` 会导致 Light 模式下窗口背景、页签和标题栏仍是系统深色。
- 窗口截图验证可用 `validation\capture-window.ps1`(按进程窗口矩形截屏,注意全屏窗口会遮挡)。
## 实现与边界
- 只读 Windows WMI 显示驱动提供的 EDID;不写 EDID、不更改分辨率或显示拓扑。
- Windows/驱动可能缓存或覆盖 EDID;不能声称数据是实时 DDC 总线抓取。
- HDMI、DP、内屏均列出,连接类型来自 WmiMonitorConnectionParams,未知则如实标注。
- 无法读取的设备、缺失扩展、未知数据块和无效校验必须明确展示,不猜测。
- EDID 表示接收端声明能力,不能证明当前链路色深、HDR、HDCP 或真实带宽。
- 解析器与 UI、Windows 读取分离;读取在后台线程完成。
- 添加解析边界/错误数据测试。打包后验证实际可见窗口、缩放、刷新和导入导出流程。
- 第一版不完整解码 DisplayID、HDMI Forum VRR/DSC;保留原始块供分析。
## 进度记录
- **v0.1.0(2026-09-25,已发布)**:目录对齐 DLPilot 三区结构;onefile 打包
(`src/build_exe.py` 生成 spec,剔除 PATH 上 Poppler/Conda 的版本化 ICU);Light
主题三件套修复(setTheme + QPalette/应用级 QSS + DWM 标题栏);右上角"关于";
标签 `EDID-Viewer-v0.1.0` 与 Release 附件已核对(66,483,477 字节)。
- 验证明细见 `VALIDATION.md`;实机验收缺口:HDMI 热插拔、多屏、跨屏 DPI、
最大化/恢复与导入导出完整交互流程。
- 根目录 `agent.md` 为仓库整体的开发需求与进度管理文档。
## 后续建议
- EDID 对比、字节差异与固件数组导出。
- HDMI Forum VRR/ALLM/FRL/DSC、DisplayID 与高级色彩数据解析。
- 与实际输出时序和桥接芯片寄存器联合诊断。
+65
View File
@@ -0,0 +1,65 @@
# EDID Viewer
Windows 10/11 显示器 EDID 查看器。使用 **PySide6 / PySide6-Fluent-Widgets**,只读,不修改显示设置。
> 只想使用工具:到 [Gitea Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases)
> 下载 `EDID-Viewer-vX.Y.Z-win64.zip`,解压双击 `EDID-Viewer.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
## 使用
- 双击 `release\EDID-Viewer.exe`(单文件,免安装)。
- 或双击 `start.cmd` 使用本机 Python 环境运行源码。
- 右上角「外观」选择 Light(浅色)、Dark(琥珀深色)、dark1(蓝色深色)、dark2(青色深色)或跟随系统;默认跟随系统,选择自动保存,系统模式响应 Windows 明暗变化。
- 图标沿用 DLPilot 的黑底/橙色标签/白字语言,文字为 EDID / Viewer;窗口、任务栏和 EXE 使用同一图标。
- 当前布局采用 Fluent Widgets:顶部设备/文件操作卡片、右上角「关于」,下方信息卡片和分组页签;Dark 使用琥珀强调色,dark1 使用蓝色强调色,dark2 使用青色强调色,主题随系统设置持久化。
- 点击「刷新设备」,从下拉框选择设备。HDMI、DP 和内屏均显示连接类型。
- 「设备与能力」显示身份和音频/HDR/色彩/HDMI 信息;「分辨率与时序」可展开 DTD 查看像素时钟、前后肩、同步宽度和极性。
- 「原始 HEX」支持选中复制。「导出 BIN」保持读取到的字节不变;「导出报告」生成含原始数据的 UTF-8 JSON。
- 支持 `.bin/.edid` 二进制和 `.hex/.txt` 纯十六进制。HEX 可以使用空格、逗号、`0x` 前缀,不接受带地址/ASCII 栏的 hexdump。
## 目录结构(对齐 DLPilot)
- `src/` — 人写代码:界面(main.py)、EDID 解析(edid.py)、Windows 读取(windows_edid.py)、主题(theme.py)、解析测试(test_edid.py)和打包流水线。
- `assets/` — 图标与生成脚本(可再生)。
- `release/` — 交付物:`EDID-Viewer.exe` 本地保留不入库;发布 zip 只上传 Gitea Release,不入库。
- `AGENTS.md` — 工程约定与任务边界(改代码前必读);`VALIDATION.md` — 验证记录。
## 环境与构建
本机:Python 3.14、PySide6 6.11.2、PySide6-Fluent-Widgets 1.11.3、PyInstaller 6.22.2。当前 `.venv` 通过 `--system-site-packages` 复用已安装依赖,不修改全局包。新电脑可以创建独立环境:
```powershell
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
.\.venv\Scripts\python.exe src\main.py
cd src && python -m unittest # 9 项解析测试
python src\build_exe.py # 打包 → release\EDID-Viewer.exe
python src\package_release.py 0.1.0 # 组装 release\EDID-Viewer-v0.1.0-win64.zip
python src\publish_gitea.py ..\release\EDID-Viewer-v0.1.0-win64.zip --insecure
```
打包为单文件 exe;spec 由 `build_exe.py` 生成到 `src/build/`(不入库),其中剔除 PATH 上其他工具(Poppler/Conda)的版本化 ICU DLL,避免 QtCore 加载失败(WinError 127)。构建前关闭正在运行的 EDID Viewer,以免文件被占用。图标源为 `assets/make_icon.py`,重新生成时需安装 Pillow(仅开发时需要)。主题配置保存在当前用户的 `QSettings: LT8619C-Tools/EDID-Viewer`。
## 能力范围
- EDID 1.x 基础身份、生产日期、名称/序列号、物理尺寸、Gamma、色深、色度坐标。
- 已建立/标准时序、基础与 CTA 详细时序;常用 CTA VIC(未映射的 VIC 显示编号)。
- 多个 CTA 扩展、音频 SAD、HDMI VSDB、CEC 地址、深色、TMDS、HF-VSDB 的 SCDC/TMDS。
- HDR EOTF、色彩标准、YCbCr 4:2:0 声明。未知块原样保留。
- 每块校验和、扩展计数、截断和数据块越界诊断。
## 限制
- 读取 `root/wmi:WmiMonitorDescriptorMethods` 中的活动设备,不保证列出禁用输出/驱动不提供 WMI 的设备。失败会显示原因,可改用离线文件。
- 数据来自 Windows/显卡驱动,可能是缓存或覆盖内容,不是实时 DDC 抓取。读取扩展失败时保留已经取得的块并报告缺失。
- EDID 能力不等于当前输出模式;不能据此确定 HDCP、线材质量、正在使用的 HDR/色深或 HDMI 版本。
- DisplayID 和 HDMI Forum VRR/ALLM/FRL/DSC 尚未详细解码;仅包含 DisplayID 时序的屏幕可能没有可解码的分辨率。范围描述符扩展偏移仅提示,未应用。
- 不承诺所有 CTA VIC/扩展标签均已覆盖。厂商首先显示标准 PNP 三字母代码,避免错误猜测品牌。
## 推荐后续挖掘
1. **桥接板时序匹配**:像素时钟、H/V 消隐、同步极性和首选模式,排查黑屏、偏色、画面偏移。
2. **EDID 差异比较**:对照板卡固件、Windows 读取值、显示器原始值,定位缓存、截断或改写。
3. **带宽与色彩**:TMDS/FRL、RGB/YCbCr、深色和 4:2:0,解释分辨率或刷新率受限。
4. **HDR 与音频**:PQ/HLG、亮度元数据、PCM 位深/采样率/声道和压缩音频能力。
5. **DisplayID / VRR / DSC**:适合高刷新率和高分辨率显示设备,作为下一阶段扩展。
+50
View File
@@ -0,0 +1,50 @@
# 验证记录 — 2026-09-23
- Python 3.14 / PySide6 6.11.2 / PyInstaller 6.22.2。
- 9 个解析测试通过,包括身份字段、标准时序、1080p/1080i DTD、CTA 音频/4K/HDR/CEC、缺失扩展、坏校验、越界、未知扩展;其中包括 1000 份随机畸形数据的无崩溃检查。
- BIN → 带 0x 前缀 HEX → BIN 一致性检查通过。
- `pip check` 通过。
- WMI 实际读取到内屏 SDC / ATNA60HU01-0;取得 256 字节,两个块校验通过;声明 384 字节,读取第三块失败。UI 正确展示读取错误和缺失警告。
- 打包 EXE 实际启动通过,QtCore/QtGui/QtWidgets 绑定文件存在。截图检查确认设备信息、中文、校验、警告均可见。
- 修复 PATH 中 Poppler ICU 被 PyInstaller 错误收集导致的 QtCore DLL 加载失败;`viewer.spec` 排除冲突 ICU,使用 Windows 系统 ICU。
- 修复系统深色主题与浅色内容背景造成的白字不可见问题,使用完整浅色 Qt 调色板。
- 当前没有可用于验证的外接 HDMI 设备;HDMI 热插拔、多屏、跨屏 DPI、最大化/恢复和 GUI 文件对话框完整流程尚未完成交互验收。检查期间窗口发生用户输入/位置变化,没有继续抢占窗口操作。
- DisplayID、高级 HDMI Forum 字段未完整解析,详见 README。
`validation/` 保存本机读取样本和合成 HDMI 测试数据,仅作本地验收材料。
## 主题与图标更新
- 新 EXE 实际启动通过,窗口展示 EDID / Viewer 黑橙白图标。
- 实际通过右上角切换 Light、Dark;截图检查文字、数据树、按钮、选择框的对比度正常。
- 跟随系统在当前 Windows 深色设置下正确显示深色。已连接 Qt `colorSchemeChanged`;未修改用户 Windows 设置进行系统热切换测试。
- Dark 选择写入 QSettings,关闭 EXE 后重新启动,选择和深色外观均保留。
- 最大化后界面正常,随后还原;没有进行跨显示器 DPI 验证。
- 9 项解析测试仍通过;三种主题的调色板检查通过。
- qt-material 2.17 已在 PySide6 环境加载;Light/Dark/System 三种模式样式表生成检查通过。
- Material 版窗口使用左侧设备/离线操作面板和右侧内容卡片,源码启动与解析测试通过;需完成本次打包后的可见 EXE 检查。
- 本次打包 EXE 实际启动通过;可见界面已切换为 Material Amber 主题,侧栏、圆角内容卡片、页签、设备数据和警告均正常显示。
- 实机操作验证 Light 与 Dark 切换,文字和数据树对比度正常;Material 版保留系统跟随选项。
- 修复 Material 选中态对比度:设备/主题下拉框选中项改为深色文字配琥珀色背景;树表格、焦点行、页签和警告提示分别使用可读的深色/浅色组合。源码测试和打包 EXE 均已更新。
- 针对截图反馈再次调整页签选中态:深色使用深灰底/琥珀字,浅色使用浅琥珀底/深琥珀字,并保留底部强调线;Light 打包窗口实测“分辨率与时序”页签可读。
- 针对 HEX/JSON 截图反馈调整 `QPlainTextEdit`/`QTextEdit` 文本选中态:深色使用蓝灰底/浅字,浅色使用浅蓝灰底/深字,避免 qt-material 的亮黄色底/白字组合。源码样式检查和解析测试通过;本次重新打包后窗口启动验证因物理 Escape 中断,未继续进行 UI 操作。
- 主题选择框和下拉列表已加宽至 148/至少 170 px,三种中文选项可完整显示;控件尺寸检查通过,EXE 已重新打包。
# 验证记录 — 2026-09-24
- 新增 `dark1`(`dark_blue.xml`,蓝色强调)和 `dark2`(`dark_cyan.xml`,青色强调)两个深色 Material 主题;原有 Light、Dark、跟随系统仍保留。
- 9 项解析测试、`pip check` 和 Python 编译检查通过;qt-material 2.17 已确认包含两个新主题资源。
- 最新 `dist\EDID-Viewer\EDID-Viewer.exe` 已重新构建并启动,进程响应正常;offscreen 窗口渲染确认两个主题的主题选择框、页签和内容面板均能显示。
- 深色主题的下拉选中项、树表格焦点行、页签、警告和 HEX/JSON 文本选择态均使用按主题的高对比配色,避免亮背景配白字。
- 当前预览使用无 EDID 样本的启动状态;Windows WMI 实际设备读取结果沿用上一条记录,未把本次 offscreen 渲染当作硬件验收。
# Fluent Widgets 重写验证(2026-09-24)
- `PySide6-Fluent-Widgets 1.11.3` 已替换 qt-material;主界面使用 Fluent 卡片、控件和树视图,主题选择持久化并支持跟随系统。
- 9 项 EDID 解析测试、Python 编译检查和离屏窗口启动通过;主题下拉框可切换 system/light/dark/dark1/dark2。
- `build.ps1` 打包通过,`dist\\EDID-Viewer\\_internal\\qfluentwidgets` 资源已收集;离线 EDID 样本可渲染并启用 BIN/JSON 导出。
# 目录重构与 onefile 打包验证(2026-09-24)
- 目录对齐 DLPilot 三区结构:`src/`(代码 + 流水线)、`assets/`(图标)、`release/`(EDID-Viewer.exe + README);`agent.md` 更名 `AGENTS.md`;`build.ps1`/`viewer.spec` 由 `src/build_exe.py`(生成 spec,ICU 排除逻辑内置)取代。
- `src/` 下 9 项解析测试通过;`src/build_exe.py` 单文件打包通过,`release\EDID-Viewer.exe` 启动验证窗口正常、图标与主题(三件套:QPalette + 应用级 QSS + DWM 标题栏)保持上一轮修复效果。
- `src/package_release.py` 组装 zip(exe + README);`src/publish_gitea.py` 由 DLPilot 同名脚本适配(tag 前缀 `EDID-Viewer-v*`),本次未实际上传。
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

+13
View File
@@ -0,0 +1,13 @@
"""Rebuild icon with Pillow (development only); matches the DLPilot brand."""
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
out = Path(__file__).resolve().parent
image = Image.new('RGB', (512,512), 'black')
draw = ImageDraw.Draw(image)
draw.rounded_rectangle((38,64,474,250),radius=34,fill='#ff9900')
font = 'C:/Windows/Fonts/arialbd.ttf'
draw.text((256,157),'EDID',font=ImageFont.truetype(font,134),anchor='mm',fill='black')
draw.text((256,368),'Viewer',font=ImageFont.truetype(font,116),anchor='mm',fill='white')
image.save(out/'edid-viewer.png')
image.save(out/'edid-viewer.ico',sizes=[(16,16),(24,24),(32,32),(48,48),(64,64),(128,128),(256,256)])
+65
View File
@@ -0,0 +1,65 @@
# EDID Viewer
Windows 10/11 显示器 EDID 查看器。使用 **PySide6 / PySide6-Fluent-Widgets**,只读,不修改显示设置。
> 只想使用工具:到 [Gitea Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases)
> 下载 `EDID-Viewer-vX.Y.Z-win64.zip`,解压双击 `EDID-Viewer.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
## 使用
- 双击 `release\EDID-Viewer.exe`(单文件,免安装)。
- 或双击 `start.cmd` 使用本机 Python 环境运行源码。
- 右上角「外观」选择 Light(浅色)、Dark(琥珀深色)、dark1(蓝色深色)、dark2(青色深色)或跟随系统;默认跟随系统,选择自动保存,系统模式响应 Windows 明暗变化。
- 图标沿用 DLPilot 的黑底/橙色标签/白字语言,文字为 EDID / Viewer;窗口、任务栏和 EXE 使用同一图标。
- 当前布局采用 Fluent Widgets:顶部设备/文件操作卡片、右上角「关于」,下方信息卡片和分组页签;Dark 使用琥珀强调色,dark1 使用蓝色强调色,dark2 使用青色强调色,主题随系统设置持久化。
- 点击「刷新设备」,从下拉框选择设备。HDMI、DP 和内屏均显示连接类型。
- 「设备与能力」显示身份和音频/HDR/色彩/HDMI 信息;「分辨率与时序」可展开 DTD 查看像素时钟、前后肩、同步宽度和极性。
- 「原始 HEX」支持选中复制。「导出 BIN」保持读取到的字节不变;「导出报告」生成含原始数据的 UTF-8 JSON。
- 支持 `.bin/.edid` 二进制和 `.hex/.txt` 纯十六进制。HEX 可以使用空格、逗号、`0x` 前缀,不接受带地址/ASCII 栏的 hexdump。
## 目录结构(对齐 DLPilot)
- `src/` — 人写代码:界面(main.py)、EDID 解析(edid.py)、Windows 读取(windows_edid.py)、主题(theme.py)、解析测试(test_edid.py)和打包流水线。
- `assets/` — 图标与生成脚本(可再生)。
- `release/` — 交付物:`EDID-Viewer.exe` 入库;发布 zip 只上传 Gitea Release,不入库。
- `AGENTS.md` — 工程约定与任务边界(改代码前必读);`VALIDATION.md` — 验证记录。
## 环境与构建
本机:Python 3.14、PySide6 6.11.2、PySide6-Fluent-Widgets 1.11.3、PyInstaller 6.22.2。当前 `.venv` 通过 `--system-site-packages` 复用已安装依赖,不修改全局包。新电脑可以创建独立环境:
```powershell
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
.\.venv\Scripts\python.exe src\main.py
cd src && python -m unittest # 9 项解析测试
python src\build_exe.py # 打包 → release\EDID-Viewer.exe
python src\package_release.py 0.1.0 # 组装 release\EDID-Viewer-v0.1.0-win64.zip
python src\publish_gitea.py ..\release\EDID-Viewer-v0.1.0-win64.zip --insecure
```
打包为单文件 exe;spec 由 `build_exe.py` 生成到 `src/build/`(不入库),其中剔除 PATH 上其他工具(Poppler/Conda)的版本化 ICU DLL,避免 QtCore 加载失败(WinError 127)。构建前关闭正在运行的 EDID Viewer,以免文件被占用。图标源为 `assets/make_icon.py`,重新生成时需安装 Pillow(仅开发时需要)。主题配置保存在当前用户的 `QSettings: LT8619C-Tools/EDID-Viewer`。
## 能力范围
- EDID 1.x 基础身份、生产日期、名称/序列号、物理尺寸、Gamma、色深、色度坐标。
- 已建立/标准时序、基础与 CTA 详细时序;常用 CTA VIC(未映射的 VIC 显示编号)。
- 多个 CTA 扩展、音频 SAD、HDMI VSDB、CEC 地址、深色、TMDS、HF-VSDB 的 SCDC/TMDS。
- HDR EOTF、色彩标准、YCbCr 4:2:0 声明。未知块原样保留。
- 每块校验和、扩展计数、截断和数据块越界诊断。
## 限制
- 读取 `root/wmi:WmiMonitorDescriptorMethods` 中的活动设备,不保证列出禁用输出/驱动不提供 WMI 的设备。失败会显示原因,可改用离线文件。
- 数据来自 Windows/显卡驱动,可能是缓存或覆盖内容,不是实时 DDC 抓取。读取扩展失败时保留已经取得的块并报告缺失。
- EDID 能力不等于当前输出模式;不能据此确定 HDCP、线材质量、正在使用的 HDR/色深或 HDMI 版本。
- DisplayID 和 HDMI Forum VRR/ALLM/FRL/DSC 尚未详细解码;仅包含 DisplayID 时序的屏幕可能没有可解码的分辨率。范围描述符扩展偏移仅提示,未应用。
- 不承诺所有 CTA VIC/扩展标签均已覆盖。厂商首先显示标准 PNP 三字母代码,避免错误猜测品牌。
## 推荐后续挖掘
1. **桥接板时序匹配**:像素时钟、H/V 消隐、同步极性和首选模式,排查黑屏、偏色、画面偏移。
2. **EDID 差异比较**:对照板卡固件、Windows 读取值、显示器原始值,定位缓存、截断或改写。
3. **带宽与色彩**:TMDS/FRL、RGB/YCbCr、深色和 4:2:0,解释分辨率或刷新率受限。
4. **HDR 与音频**:PQ/HLG、亮度元数据、PCM 位深/采样率/声道和压缩音频能力。
5. **DisplayID / VRR / DSC**:适合高刷新率和高分辨率显示设备,作为下一阶段扩展。
+4
View File
@@ -0,0 +1,4 @@
PySide6==6.11.2
PySide6-Fluent-Widgets==1.11.3
pyinstaller==6.22.2
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
把 src/main.py 打包成单文件 exe(工具名: EDID-Viewer)
================================================
与 DLPilot/src/build_exe.py 同一套路: onefile + --noconsole, Python 解释器、
PySide6 和 PySide6-Fluent-Widgets 全部打进一个 exe, 目标机器无需安装依赖。
用法:
python build_exe.py 自动安装 PyInstaller(如缺)并打包
python build_exe.py --debug 额外打一个带控制台的变体
产物: release/EDID-Viewer.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', 'edid-viewer.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/edid-viewer.ico(先运行 assets/make_icon.py), 本次打包不带图标')
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('EDID-Viewer', 'False')
if build_console:
# 调试版: 带控制台, 异常栈可见
build('EDID-Viewer-console', 'True')
if __name__ == '__main__':
main()
+157
View File
@@ -0,0 +1,157 @@
"""Bounds-checked EDID 1.x / CTA parser. Unknown fields retain their bytes."""
import hashlib
import re
VICS = {1:'640×480p 60 Hz',2:'720×480p 60 Hz 4:3',3:'720×480p 60 Hz 16:9',
4:'1280×720p 60 Hz',5:'1920×1080i 60 Hz',16:'1920×1080p 60 Hz',
17:'720×576p 50 Hz 4:3',18:'720×576p 50 Hz 16:9',19:'1280×720p 50 Hz',
20:'1920×1080i 50 Hz',31:'1920×1080p 50 Hz',32:'1920×1080p 24 Hz',
33:'1920×1080p 25 Hz',34:'1920×1080p 30 Hz',60:'1280×720p 24 Hz',
61:'1280×720p 25 Hz',62:'1280×720p 30 Hz',63:'1920×1080p 120 Hz',64:'1920×1080p 100 Hz',
93:'3840×2160p 24 Hz',94:'3840×2160p 25 Hz',95:'3840×2160p 30 Hz',
96:'3840×2160p 50 Hz',97:'3840×2160p 60 Hz',98:'4096×2160p 24 Hz',
99:'4096×2160p 25 Hz',100:'4096×2160p 30 Hz',101:'4096×2160p 50 Hz',102:'4096×2160p 60 Hz',
117:'3840×2160p 100 Hz',118:'3840×2160p 120 Hz'}
EST = ['720×400 70 Hz','720×400 88 Hz','640×480 60 Hz','640×480 67 Hz','640×480 72 Hz',
'640×480 75 Hz','800×600 56 Hz','800×600 60 Hz','800×600 72 Hz','800×600 75 Hz',
'832×624 75 Hz','1024×768i 87 Hz','1024×768 60 Hz','1024×768 70 Hz','1024×768 75 Hz',
'1280×1024 75 Hz','1152×870 75 Hz']
def read_file(path):
from pathlib import Path
data = Path(path).read_bytes()
if len(data) > 32768 * 4:
raise ValueError('文件过大:最大 128 KiB')
if Path(path).suffix.lower() in ('.hex', '.txt'):
s = data.decode('utf-8-sig').strip()
s = re.sub(r'0[xX]([0-9a-fA-F]{2})', r'\1', s)
s = re.sub(r'[\s,{};]', '', s)
if not re.fullmatch(r'(?:[0-9a-fA-F]{2})+', s):
raise ValueError('HEX 文件仅接受十六进制字节,不接受偏移地址或 ASCII 栏')
return bytes.fromhex(s)
return data
def hex_dump(raw):
return '\n'.join(f'{i:04X} ' + ' '.join(f'{x:02X}' for x in raw[i:i+16]) +
' |' + ''.join(chr(x) if 32 <= x < 127 else '.' for x in raw[i:i+16]) + '|'
for i in range(0, len(raw), 16))
def dtd(b, source):
clock = int.from_bytes(b[:2], 'little') * 10000
h = b[2] + ((b[4] & 240) << 4); hb = b[3] + ((b[4] & 15) << 8)
v = b[5] + ((b[7] & 240) << 4); vb = b[6] + ((b[7] & 15) << 8)
hf = b[8] + ((b[11] & 192) << 2); hs = b[9] + ((b[11] & 48) << 4)
vf = (b[10] >> 4) + ((b[11] & 12) << 2); vs = (b[10] & 15) + ((b[11] & 3) << 4)
interlace = bool(b[17] & 128)
total_v = (v + vb) * 2 + 1 if interlace else v + vb
rate = clock / ((h + hb) * total_v) if h + hb and total_v else 0
sync = (b[17] >> 3) & 3
polarity = ('H' + ('+' if b[17] & 2 else '-') + ' V' + ('+' if b[17] & 4 else '-')) if sync == 3 else '非数字分离同步'
return {'来源':source, '分辨率':f'{h}×{v*2 if interlace else v}' + ('i' if interlace else 'p'),
'刷新率':f'{rate*2 if interlace else rate:.3f} Hz' + ('(场率)' if interlace else ''),
'像素时钟 MHz':clock / 1e6, '水平有效/前肩/同步/后肩':[h,hf,hs,hb-hf-hs],
'垂直有效/前肩/同步/后肩(隔行按场)':[v,vf,vs,vb-vf-vs], '同步':polarity}
def parse(raw):
if len(raw) < 128:
raise ValueError(f'EDID 基础块不足 128 字节,实际 {len(raw)}')
r = {'基本信息':{}, '时序':[], '能力':[], '数据块':[], '校验':[], '警告':[]}
info, warnings = r['基本信息'], r['警告']
if raw[:8] != bytes.fromhex('00 ff ff ff ff ff ff 00'):
warnings.append('EDID 文件头无效;以下解析仅供诊断')
if raw[18] != 1:
warnings.append('非 EDID 1.x:未解析,仅保留原始数据')
return r
if len(raw) % 128: warnings.append('末尾存在不足 128 字节的数据')
expected = (raw[126] + 1) * 128
if expected != len(raw): warnings.append(f'声明 {expected} 字节,实际 {len(raw)} 字节')
for n in range(len(raw)//128):
block = raw[n*128:(n+1)*128]
r['校验'].append(f'块 {n}: ' + ('通过' if sum(block)%256 == 0 else '失败'))
pnp = int.from_bytes(raw[8:10], 'big')
manufacturer = ''.join(chr(((pnp >> s) & 31) + 64) for s in (10,5,0))
info.update({'厂家 PNP 代码':manufacturer, '产品编号':f'0x{int.from_bytes(raw[10:12], "little"):04X}',
'数字序列号':int.from_bytes(raw[12:16], 'little'), 'EDID 版本':f'{raw[18]}.{raw[19]}',
'生产日期':f'{raw[17]+1990} 年 / '+('型号年份' if raw[16]==255 else f'第 {raw[16]} 周' if raw[16] else '周未指定'),
'尺寸':f'{raw[21]} × {raw[22]} cm' if raw[21] and raw[22] else '未给出完整物理尺寸(可能编码宽高比)',
'Gamma':(raw[23]+100)/100 if raw[23]!=255 else '未定义',
'扩展块数':raw[126], 'SHA-256':hashlib.sha256(raw).hexdigest()})
if raw[20]&128:
info['输入类型'] = '数字'
if raw[19]>=4:
info['接口声明'] = {0:'未定义',1:'DVI',2:'HDMI-A',3:'HDMI-B',4:'MDDI',5:'DisplayPort'}.get(raw[20]&15,'保留')
info['色深 bpc'] = {0:'未定义',1:6,2:8,3:10,4:12,5:14,6:16}.get((raw[20]>>4)&7,'保留')
else: info['输入类型'] = '模拟'
for idx, mode in enumerate(EST):
if raw[35+idx//8] & (128>>(idx%8)): r['时序'].append({'来源':'已建立时序','分辨率':mode})
for i in range(38,54,2):
a,b = raw[i:i+2]
if (a,b) in ((1,1),(0,0)): continue
h = (a+31)*8
ratio = (1 if raw[19]<3 else 10/16, 3/4, 4/5, 9/16)[b>>6]
r['时序'].append({'来源':'标准时序','分辨率':f'{h}×{round(h*ratio)}','刷新率':f'{(b&63)+60} Hz'})
for offset in range(54,126,18):
b=raw[offset:offset+18]
if any(b[:2]):
r['时序'].append(dtd(b, '基础 DTD'+('(首选)' if offset==54 and (raw[19]>=4 or raw[24]&2) else '')))
elif b[:3]==b'\0\0\0':
if b[3] in (252,255,254): info[{252:'显示器名称',255:'文本序列号',254:'描述文本'}[b[3]]] = b[5:18].decode('ascii','replace').strip('\x00\n ')
elif b[3]==253:
info['频率范围原始值'] = f'垂直 {b[5]}–{b[6]} Hz;水平 {b[7]}–{b[8]} kHz;最大像素时钟 {b[9]*10} MHz'
if b[4]: warnings.append('范围描述符有扩展偏移,频率范围显示原始字段,暂未应用偏移')
coords=[]
low=[(raw[25]>>s)&3 for s in (6,4,2,0)]+[(raw[26]>>s)&3 for s in (6,4,2,0)]
for i,name in enumerate(('Rx','Ry','Gx','Gy','Bx','By','Wx','Wy')): coords.append(f'{name}={(raw[27+i]*4+low[i])/1024:.4f}')
r['能力'].append('色度坐标:'+', '.join(coords))
for n in range(1,min(len(raw)//128,raw[126]+1)):
b=raw[n*128:(n+1)*128]
r['数据块'].append({'块':n,'类型':{2:'CTA-861',112:'DisplayID(暂未解码)'}.get(b[0],f'未知 0x{b[0]:02X}'),'原始':b.hex(' ')})
if b[0]!=2: continue
r['能力'].append(f'CTA 块 {n} / revision {b[1]}:'+', '.join(label for bit,label in ((64,'基本音频'),(32,'YCbCr 4:4:4'),(16,'YCbCr 4:2:2')) if b[3]&bit))
end=b[2]
if end==0: continue
if not 4<=end<=127:
warnings.append(f'CTA 块 {n} DTD 偏移无效:{end}'); continue
p=4
while p<end:
tag,length=b[p]>>5,b[p]&31
if not b[p]: break
if p+1+length>end:
warnings.append(f'CTA 块 {n} 数据块越界 @{p}'); break
x=b[p+1:p+1+length]; p+=length+1
entry={'块':n,'tag':tag,'原始':x.hex(' ')}; r['数据块'].append(entry)
if tag==2:
for svd in x:
vic=svd&127 if 129<=svd<=192 else svd
r['时序'].append({'来源':f'CTA VIC {vic}'+('(原生)' if 129<=svd<=192 else ''),'分辨率':VICS.get(vic,'尚未映射;查看 VIC 编号')})
elif tag==1:
if len(x)%3: warnings.append('音频数据块长度不是 3 的倍数')
for j in range(0,len(x)-2,3):
a,f,c=x[j:j+3]; fmt=(a>>3)&15
rates='/'.join(str(v) for k,v in enumerate((32,44.1,48,88.2,96,176.4,192)) if f&(1<<k))
bits='/'.join(str(v) for k,v in enumerate((16,20,24)) if c&(1<<k))
r['能力'].append(f'音频:'+{1:'LPCM',2:'AC-3',7:'DTS',10:'E-AC-3',11:'DTS-HD',12:'MAT'}.get(fmt,f'格式代码 {fmt}')+f' / {(a&7)+1} 声道 / {rates} kHz'+(f' / {bits} bit' if fmt==1 else f' / 格式字段 0x{c:02X}'))
elif tag==3 and len(x)>=3:
oui=int.from_bytes(x[:3],'little'); entry['OUI']=f'{oui:06X}'
if oui==0x000c03:
r['能力'].append('HDMI VSDB')
if len(x)>=5: r['能力'].append('CEC 物理地址:'+'.'.join(str(v) for v in (x[3]>>4,x[3]&15,x[4]>>4,x[4]&15)))
if len(x)>=6: r['能力'].append('HDMI 深色:'+('/'.join(str(v) for k,v in ((4,10),(5,12),(6,16)) if x[5]&(1<<k)) or '未声明')+' bpc')
if len(x)>=7 and x[6]: r['能力'].append(f'最大 TMDS 时钟:{x[6]*5} MHz')
elif oui==0xc45dd8:
r['能力'].append('HDMI Forum VSDB(VRR/FRL/DSC 字段暂未解码)')
if len(x)>=5 and x[4]: r['能力'].append(f'最大 TMDS 字符速率:{x[4]*5} MHz')
if len(x)>=6: r['能力'].append('SCDC:'+('支持' if x[5]&128 else '未声明'))
elif tag==7 and x:
entry['扩展 tag']=x[0]
if x[0]==6 and len(x)>=3:
r['能力'].append('HDR EOTF:'+', '.join(name for k,name in enumerate(('传统 SDR','传统 HDR','PQ / ST 2084','HLG')) if x[1]&(1<<k)))
r['能力'].append(f'HDR 静态元数据类型位图:0x{x[2]:02X}')
elif x[0]==14:
r['能力'].append('仅 YCbCr 4:2:0 VIC:'+', '.join(f'{v} ({VICS.get(v,"未映射")})' for v in x[1:]))
elif x[0]==15: r['能力'].append('YCbCr 4:2:0 能力映射:'+x[1:].hex(' ')+'(按本 CTA SVD 顺序)')
elif x[0]==5 and len(x)>=3:
r['能力'].append('色彩标准:'+', '.join(name for k,name in enumerate(('xvYCC601','xvYCC709','sYCC601','opYCC601','opRGB','BT.2020 cYCC','BT.2020 YCC','BT.2020 RGB')) if x[1]&(1<<k)))
for o in range(end,110,18):
if any(b[o:o+2]): r['时序'].append(dtd(b[o:o+18],f'CTA {n} DTD'))
return r
+391
View File
@@ -0,0 +1,391 @@
"""Fluent EDID viewer for Windows display devices and offline files."""
import ctypes
import json
import sys
from pathlib import Path
from PySide6.QtCore import QThread, Qt, QTimer, Signal
from PySide6.QtGui import QFont, QFontDatabase, QIcon
from PySide6.QtWidgets import (
QApplication,
QFileDialog,
QHBoxLayout,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QSizePolicy,
QSplitter,
QTabWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from qfluentwidgets import (
BodyLabel,
CaptionLabel,
CardWidget,
ComboBox,
FluentIcon,
PlainTextEdit,
PrimaryPushButton,
PushButton,
StrongBodyLabel,
SubtitleLabel,
TreeWidget,
)
from edid import hex_dump, parse, read_file
from theme import ThemeManager
from windows_edid import scan
APP_NAME = "EDID Viewer"
APP_VERSION = "0.1.0"
REPO_URL = "https://gitea.hulk.wang/hulk/HW-Debug-Tools"
def icon_path():
"""Packaged onefile: icon is extracted under sys._MEIPASS/assets; source: ../assets."""
if hasattr(sys, "_MEIPASS"):
return Path(sys._MEIPASS) / "assets" / "edid-viewer.ico"
return Path(__file__).resolve().parent.parent / "assets" / "edid-viewer.ico"
class Scanner(QThread):
result = Signal(object)
failure = Signal(str)
def run(self):
try:
self.result.emit(scan())
except Exception as exc:
self.failure.emit(str(exc))
class Window(QMainWindow):
"""Main Fluent surface; parser and Windows access remain separate modules."""
def __init__(self, theme_manager=None):
super().__init__()
self.theme_manager = theme_manager or ThemeManager(QApplication.instance())
self.setWindowTitle("EDID Viewer · HDMI 显示信息")
self.resize(1240, 820)
self.setMinimumSize(900, 620)
self.raw = b""
self.report = {}
self.worker = None
self._build_ui()
self.theme_manager.set_window(self)
self.bin_button.setEnabled(False)
self.export_button.setEnabled(False)
QTimer.singleShot(0, self.start_scan)
def _button(self, text, callback, primary=False, icon=None):
button = PrimaryPushButton(self) if primary else PushButton(self)
button.setText(text)
if icon is not None:
button.setIcon(icon)
button.clicked.connect(callback)
return button
def _card(self, title, subtitle=None, parent=None):
card = CardWidget(parent or self)
layout = QVBoxLayout(card)
layout.setContentsMargins(16, 14, 16, 16)
layout.setSpacing(9)
layout.addWidget(StrongBodyLabel(title, card))
if subtitle:
caption = CaptionLabel(subtitle, card)
caption.setWordWrap(True)
layout.addWidget(caption)
return card, layout
def _text(self, mono=False, parent=None):
text = PlainTextEdit(parent or self)
text.setReadOnly(True)
text.setLineWrapMode(QPlainTextEdit.NoWrap)
if mono:
text.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
return text
def _build_ui(self):
root = QWidget(self)
root.setObjectName("root")
outer = QVBoxLayout(root)
outer.setContentsMargins(22, 18, 22, 18)
outer.setSpacing(14)
header = QHBoxLayout()
title_box = QVBoxLayout()
title_box.setSpacing(1)
title_box.addWidget(SubtitleLabel("EDID Viewer", root))
title_box.addWidget(BodyLabel("显示设备能力与原始数据", root))
header.addLayout(title_box)
header.addStretch(1)
header.addWidget(BodyLabel("外观", root))
self.theme_choice = ComboBox(root)
self.theme_choice.setAccessibleName("外观主题")
for label, value in (
("跟随系统", "system"),
("Light · 浅色", "light"),
("Dark · 深色", "dark"),
("dark1 · 蓝色", "dark1"),
("dark2 · 青色", "dark2"),
):
self.theme_choice.addItem(label)
self.theme_choice.setItemData(self.theme_choice.count() - 1, value)
self.theme_choice.setCurrentIndex(self.theme_choice.findData(self.theme_manager.mode))
self.theme_choice.currentIndexChanged.connect(
lambda: self.theme_manager.set_mode(self.theme_choice.currentData())
)
header.addWidget(self.theme_choice)
header.addWidget(self._button("关于", self.show_about))
outer.addLayout(header)
note = CaptionLabel(
"Windows 驱动提供的 EDID,可能包含缓存或覆盖;EDID 是接收端声明能力,不代表当前实际输出。",
root,
)
note.setWordWrap(True)
outer.addWidget(note)
split = QSplitter(Qt.Vertical, root)
split.setChildrenCollapsible(False)
split.setHandleWidth(8)
split.addWidget(self._build_sidebar())
split.addWidget(self._build_content())
split.setStretchFactor(0, 0)
split.setStretchFactor(1, 1)
split.setSizes([150, 620])
outer.addWidget(split, 1)
self.setCentralWidget(root)
def _build_sidebar(self):
host = QWidget(self)
host.setMinimumHeight(132)
host.setMaximumHeight(210)
layout = QHBoxLayout(host)
layout.setContentsMargins(2, 2, 2, 2)
layout.setSpacing(12)
card, body = self._card("显示设备", "选择活动输出并读取驱动提供的原始 EDID。", host)
card.setMinimumWidth(360)
self.devices = ComboBox(card)
self.devices.setMinimumHeight(38)
self.devices.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
device_row = QHBoxLayout()
device_row.setSpacing(8)
device_row.addWidget(self.devices, 1)
self.refresh_button = self._button("刷新设备", self.start_scan, icon=FluentIcon.SYNC)
self.refresh_button.setMinimumWidth(116)
device_row.addWidget(self.refresh_button)
body.addLayout(device_row)
layout.addWidget(card)
card, body = self._card("离线文件", "适用于 .bin、.edid、.hex 和纯十六进制 .txt。", host)
card.setMinimumWidth(430)
self.open_button = self._button("打开 EDID", self.open_file, primary=True, icon=FluentIcon.FOLDER)
self.bin_button = self._button("导出 BIN", self.save_bin, icon=FluentIcon.DOWNLOAD)
self.export_button = self._button("导出 JSON 报告", self.save_report, icon=FluentIcon.SAVE)
file_row = QHBoxLayout()
file_row.setSpacing(8)
for button in (self.open_button, self.bin_button, self.export_button):
button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
file_row.addWidget(button, 1)
body.addLayout(file_row)
layout.addWidget(card)
layout.setStretch(0, 1)
layout.setStretch(1, 1)
return host
def _build_content(self):
card = CardWidget(self)
layout = QVBoxLayout(card)
layout.setContentsMargins(18, 16, 18, 18)
layout.setSpacing(12)
self.summary = BodyLabel("正在读取设备…", card)
self.summary.setWordWrap(True)
layout.addWidget(self.summary)
self.tabs = QTabWidget(card)
self.tabs.setDocumentMode(True)
self.tree = TreeWidget(card)
self.tree.setHeaderLabels(["项目", "详细信息"])
self.tree.setColumnWidth(0, 260)
self.tree.setAlternatingRowColors(True)
self.tabs.addTab(self.tree, "设备与能力")
self.timings = TreeWidget(card)
self.timings.setHeaderLabels(["来源 / 字段", "分辨率 / 数值", "刷新率"])
self.timings.setColumnWidth(0, 255)
self.timings.setColumnWidth(1, 360)
self.timings.setAlternatingRowColors(True)
self.tabs.addTab(self.timings, "分辨率与时序")
self.hex = self._text(mono=True, parent=card)
self.tabs.addTab(self.hex, "原始 HEX")
self.details = self._text(mono=True, parent=card)
self.tabs.addTab(self.details, "完整解析 JSON")
layout.addWidget(self.tabs, 1)
self.devices.currentIndexChanged.connect(self.select_device)
return card
def show_about(self):
QMessageBox.about(
self,
f"关于 {APP_NAME}",
f"{APP_NAME} v{APP_VERSION}\n\n"
"Windows 显示器 EDID 查看器 · 只读,不修改显示设置\n"
"PySide6-Fluent-Widgets UI\n\n"
f"{REPO_URL}",
)
def start_scan(self):
if self.worker and self.worker.isRunning():
return
self.refresh_button.setEnabled(False)
self.open_button.setEnabled(False)
self.statusBar().showMessage("正在读取活动设备…")
self.worker = Scanner(self)
self.worker.result.connect(self.scanned)
self.worker.failure.connect(self.scan_failed)
self.worker.finished.connect(lambda: self.refresh_button.setEnabled(True))
self.worker.finished.connect(lambda: self.open_button.setEnabled(True))
self.worker.start()
def scan_failed(self, message):
self.statusBar().showMessage("设备读取失败,可打开离线 EDID 文件")
self.summary.setText("Windows EDID 读取失败:" + message)
def scanned(self, items):
old = self.devices.currentData()
old_id = old.get("instance") if old else None
self.devices.blockSignals(True)
self.devices.clear()
selected = 0
for index, item in enumerate(items):
name = item["instance"]
try:
info = parse(item["raw"])["基本信息"]
name = info.get("显示器名称", info.get("厂家 PNP 代码", name))
except ValueError:
pass
self.devices.addItem(
f'{item["连接"]} · {name} · {item["instance"]}',
userData=item,
)
if item["instance"] == old_id:
selected = index
self.devices.setCurrentIndex(selected)
self.devices.blockSignals(False)
self.select_device()
self.statusBar().showMessage(f"找到 {len(items)} 个活动设备;未启用的输出可能不会列出")
def select_device(self, *_):
item = self.devices.currentData()
if item:
self.show_raw(item["raw"], item["instance"] + " / " + item["连接"], item["error"])
else:
self.show_raw(b"", "未找到活动显示器。可刷新或打开离线文件。")
def show_raw(self, raw, source, error=""):
self.raw = raw
self.report = {}
self.tree.clear()
self.timings.clear()
self.details.clear()
self.hex.setPlainText(hex_dump(raw))
self.bin_button.setEnabled(bool(raw))
self.export_button.setEnabled(False)
try:
report = parse(raw)
except ValueError as exc:
self.summary.setText(f"{source}\n{error or str(exc)}")
return
self.report = {"来源": source, "读取错误": error, **report, "原始 HEX": raw.hex(" ")}
self.export_button.setEnabled(True)
messages = report["警告"] + [item for item in report["校验"] if "失败" in item]
if error:
messages.insert(0, error)
status = "各块校验通过" if not messages else "⚠ " + ";".join(messages)
self.summary.setText(f"{source}\n{len(raw)} 字节 · {status}")
for group in ("基本信息", "能力", "校验", "警告", "数据块"):
parent = QTreeWidgetItem(self.tree, [group])
value = report[group]
rows = value.items() if isinstance(value, dict) else enumerate(value, 1)
for key, item in rows:
rendered = json.dumps(item, ensure_ascii=False) if isinstance(item, (dict, list)) else str(item)
QTreeWidgetItem(parent, [str(key), rendered])
parent.setExpanded(group != "数据块")
for timing in report["时序"]:
parent = QTreeWidgetItem(self.timings, [timing["来源"], timing["分辨率"], timing.get("刷新率", "")])
for key, value in timing.items():
if key not in ("来源", "分辨率", "刷新率"):
QTreeWidgetItem(parent, [key, str(value)])
self.details.setPlainText(json.dumps(self.report, ensure_ascii=False, indent=2))
def open_path(self, path):
try:
raw = read_file(path)
self.devices.blockSignals(True)
self.devices.setCurrentIndex(-1)
self.devices.blockSignals(False)
self.show_raw(raw, "文件:" + str(path))
except (OSError, ValueError, UnicodeError) as exc:
QMessageBox.warning(self, "无法打开", str(exc))
def open_file(self):
path, _ = QFileDialog.getOpenFileName(
self,
"打开 EDID",
"",
"EDID (*.bin *.edid *.hex *.txt);;全部文件 (*)",
)
if path:
self.open_path(path)
def save_bin(self):
path, _ = QFileDialog.getSaveFileName(self, "导出原始 EDID", "edid.bin", "BIN (*.bin)")
if path:
self.write(path, self.raw)
def save_report(self):
path, _ = QFileDialog.getSaveFileName(self, "导出报告", "edid-report.json", "JSON (*.json)")
if path:
self.write(path, json.dumps(self.report, ensure_ascii=False, indent=2).encode("utf-8"))
def write(self, path, data):
try:
Path(path).write_bytes(data)
self.statusBar().showMessage("已保存:" + path)
except OSError as exc:
QMessageBox.warning(self, "保存失败", str(exc))
def closeEvent(self, event):
if self.worker and self.worker.isRunning():
self.statusBar().showMessage("正在读取,请等待读取完成后关闭(最长约 45 秒)")
event.ignore()
else:
event.accept()
def main():
if sys.platform == "win32":
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("LT8619C.Tools.EDIDViewer")
app = QApplication(sys.argv)
app.setApplicationName(APP_NAME)
app.setFont(QFont("Microsoft YaHei UI", 10))
app.setWindowIcon(QIcon(str(icon_path())))
theme_manager = ThemeManager(app)
window = Window(theme_manager)
window.show()
return app.exec()
if __name__ == "__main__":
sys.exit(main())
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
组装 EDID-Viewer 发布包: exe + README → 一个 zip(解压即用)
================================================
把主 README 复制进 release/ 后, 与单文件 exe 一起压成 zip —— 两者内容一致。
zip 解压后根目录为 EDID-Viewer/:
EDID-Viewer.exe 主程序(先运行 build_exe.py 打包)
README.md 使用说明
用法:
python package_release.py 版本号自动取最近 git tag(EDID-Viewer-v*)
python package_release.py 0.1.0 手动指定版本号
产物: release/EDID-Viewer-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():
"""从最近的 EDID-Viewer-v* tag 推断版本号, 无 tag 则报错提示手动指定"""
try:
out = subprocess.check_output(
['git', 'describe', '--tags', '--abbrev=0', '--match', 'EDID-Viewer-v*'],
cwd=BASE, stderr=subprocess.DEVNULL)
return out.decode().strip().removeprefix('EDID-Viewer-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, 'EDID-Viewer.exe')
readme = os.path.join(BASE, 'README.md')
if not os.path.exists(exe):
sys.exit('缺少 release/EDID-Viewer.exe, 先运行: python build_exe.py')
if not os.path.exists(readme):
sys.exit(f'缺少 {readme}')
shutil.copy2(readme, os.path.join(RELEASE, 'README.md'))
zip_name = f'EDID-Viewer-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'EDID-Viewer/{os.path.basename(exe)}')
z.write(os.path.join(RELEASE, 'README.md'), 'EDID-Viewer/README.md')
print(f'发布文件夹: {RELEASE} (exe + README)')
print(f'发布包: {zip_path} ({os.path.getsize(zip_path) / 1024 / 1024:.1f} MB)')
print('下一步上传: python publish_gitea.py', zip_name)
if __name__ == '__main__':
main()
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
创建/更新 Gitea Release 并上传发布 zip(纯标准库, 无需安装依赖)
================================================================
用法:
python publish_gitea.py EDID-Viewer-v0.1.0-win64.zip
python publish_gitea.py <zip> --notes-file release/notes-v0.1.0.md
python publish_gitea.py <zip> --tag EDID-Viewer-v0.1.0 --replace
行为:
- tag 与版本默认取 zip 文件名(EDID-Viewer-vX.Y.Z-win64.zip → EDID-Viewer-vX.Y.Z)
- release 不存在则创建(tag 不存在时 Gitea 在默认分支头自动建 tag);
已存在则复用, 仅上传/更新资产
- release 上已有同名资产时: 默认跳过, --replace 删旧再传
- API token: --token 参数或环境变量 GITEA_TOKEN(需该仓库写权限)
"""
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 = """## EDID-Viewer {version}
Windows 显示器 EDID 查看器(LT8619C 调试工具集配套): 只读查看活动显示设备
或离线文件的 EDID —— 厂家/序列号/分辨率时序、CTA/HDMI/音频/HDR 能力、
校验诊断, 支持 BIN/HEX 导入与 BIN/JSON 报告导出。
### 安装
下载 `EDID-Viewer-v{version}-win64.zip`, 解压后双击 `EDID-Viewer.exe`(Windows 10/11 x64, 免安装)。
### 包内容
- `EDID-Viewer.exe` — 主程序
- `README.md` — 完整使用说明
"""
def git_remote_info():
"""从 origin 远程地址解析 API base 与 owner/repo(ssh 端口不影响 https API)"""
url = subprocess.check_output(['git', 'remote', 'get-url', 'origin']).decode().strip()
m = re.match(r'(?:ssh://git@|git@)([\w.-]+)(?::\d+)?[:/]([\w.-]+)/([\w.-]+?)(?:\.git)?$', url)
if not m:
sys.exit(f'无法从 origin 解析 Gitea 地址: {url}')
host, owner, repo = m.groups()
return f'https://{host}', owner, repo
class Api:
def __init__(self, base, owner, repo, token, insecure):
self.api = f'{base}/api/v1/repos/{owner}/{repo}'
self.headers = {'Authorization': f'token {token}'}
self.ctx = ssl._create_unverified_context() if insecure else None
def request(self, method, path, data=None, headers=None, retries=4):
"""发请求; 实例 TLS 间歇性抽风(SSL UNEXPECTED_EOF), 连接级错误自动重试"""
last = None
for attempt in range(retries):
req = urllib.request.Request(self.api + path, data=data, method=method,
headers={**self.headers, **(headers or {})})
try:
with urllib.request.urlopen(req, context=self.ctx) as resp:
body = resp.read()
return resp.status, json.loads(body) if body else None
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b'{}')
except (urllib.error.URLError, ssl.SSLError, OSError) as e:
last = e
time.sleep(1.0 + attempt)
raise RuntimeError(f'Gitea 请求连续 {retries} 次失败: {last}')
def get_json(self, path):
status, data = self.request('GET', path)
return data if status == 200 else None
def upload_asset(self, release_id, filepath, replace):
name = os.path.basename(filepath)
status, rel = self.request('GET', f'/releases/{release_id}')
if status != 200:
sys.exit(f'读取 release {release_id} 失败: {status} {rel}')
# Gitea 版本差异: assets 可能是 {items:[...]} 或纯数组
assets = rel.get('assets') or []
if isinstance(assets, dict):
assets = assets.get('items', [])
for asset in assets:
if asset['name'] == name:
if not replace:
print(f'资产 {name} 已存在, 跳过(覆盖请加 --replace)')
return
self.request('DELETE', f"/releases/{release_id}/assets/{asset['id']}")
print(f'已删除旧资产 {name}')
boundary = uuid.uuid4().hex
payload = open(filepath, 'rb').read()
body = (f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'
f'Content-Type: application/octet-stream\r\n\r\n').encode() + payload + \
f'\r\n--{boundary}--\r\n'.encode()
status, data = self.request(
'POST', f'/releases/{release_id}/assets?name={name}', body,
{'Content-Type': f'multipart/form-data; boundary={boundary}'})
if status != 201:
sys.exit(f'上传资产失败: {status} {data}')
print(f'已上传资产: {name} ({len(payload) / 1024 / 1024:.1f} MB)')
def main():
ap = argparse.ArgumentParser()
ap.add_argument('zipfile_path', help='发布 zip(release/ 下)')
ap.add_argument('--tag', help='release 标签, 默认取 zip 文件名去掉 -win64.zip')
ap.add_argument('--notes-file', help='release 说明 markdown 文件, 默认用内置模板')
ap.add_argument('--token', default=os.environ.get('GITEA_TOKEN'), help='API token')
ap.add_argument('--replace', action='store_true', help='覆盖已存在的同名资产')
ap.add_argument('--insecure', action='store_true', help='跳过 TLS 证书校验(自签证书)')
args = ap.parse_args()
if not os.path.exists(args.zipfile_path):
sys.exit(f'找不到 {args.zipfile_path}')
if not args.token:
sys.exit('缺少 API token: 传 --token 或设环境变量 GITEA_TOKEN')
tag = args.tag or re.sub(r'-win64\.zip$', '', os.path.basename(args.zipfile_path))
version = tag.removeprefix('EDID-Viewer-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'EDID-Viewer 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()
+50
View File
@@ -0,0 +1,50 @@
import random
import unittest
from edid import parse,dtd
def checksum(b):
b[127]=(-sum(b[:127]))&255
return bytes(b)
def base(ext=0):
b=bytearray(128); b[:8]=bytes.fromhex('00ffffffffffff00'); b[8:10]=bytes.fromhex('4c2d')
b[18:20]=bytes((1,4)); b[20]=0xa2; b[38:54]=b'\x01\x01'*8; b[126]=ext
b[54:72]=bytes.fromhex('023a801871382d40582c4500fd1e1100001e')
b[72:90]=b'\0\0\0\xfc\0TEST PANEL\n '
return bytearray(checksum(b))
class Tests(unittest.TestCase):
def test_identity_and_dtd(self):
r=parse(base()); self.assertEqual(r['基本信息']['显示器名称'],'TEST PANEL')
self.assertEqual(r['基本信息']['厂家 PNP 代码'],'SAM')
t=r['时序'][0]; self.assertEqual(t['分辨率'],'1920×1080p'); self.assertEqual(t['刷新率'],'60.000 Hz')
self.assertEqual(t['同步'],'H+ V+'); self.assertEqual(t['水平有效/前肩/同步/后肩'],[1920,88,44,148])
def test_standard(self):
b=base(); b[38:40]=bytes((129,192)); r=parse(checksum(b))
self.assertEqual(r['时序'][0]['分辨率'],'1280×720'); self.assertEqual(r['时序'][0]['刷新率'],'60 Hz')
def test_cta(self):
b=bytearray(128); payload=bytes.fromhex('429061 23090707 67030c0012343078 e3060c01')
b[:4]=bytes((2,3,4+len(payload),0x70)); b[4:4+len(payload)]=payload
r=parse(bytes(base(1))+checksum(b)); text=str(r)
self.assertIn('1.2.3.4',text); self.assertIn('PQ / ST 2084',text); self.assertIn('3840×2160p 60 Hz',text)
self.assertIn('600 MHz',text); self.assertIn('LPCM',text)
def test_missing_extension(self):
self.assertTrue(parse(base(2))['警告'])
def test_bad_checksum_and_header(self):
b=base(); b[0]=1; r=parse(b); self.assertTrue(r['警告']); self.assertIn('失败',r['校验'][0])
def test_bounds(self):
with self.assertRaises(ValueError): parse(b'\0'*127)
b=bytearray(128); b[:5]=bytes((2,3,6,0,0x5f))
self.assertIn('越界',str(parse(bytes(base(1))+checksum(b))['警告']))
def test_unknown_extension(self):
b=bytearray(128); b[0]=0x70
self.assertIn('暂未解码',str(parse(bytes(base(1))+checksum(b))))
def test_interlaced(self):
b=bytes.fromhex('011d8018711c1620582c2500fd1e1100009e')
t=dtd(b,'test'); self.assertEqual(t['分辨率'],'1920×1080i'); self.assertEqual(t['刷新率'],'60.000 Hz(场率)')
def test_random_malformed(self):
rng=random.Random(42)
for _ in range(1000):
b=bytearray(rng.randbytes(rng.randint(128,600))); b[18]=1; parse(b)
if __name__=='__main__': unittest.main()
+129
View File
@@ -0,0 +1,129 @@
"""Theme persistence and Fluent Widgets integration."""
import ctypes
import os
from PySide6.QtCore import QObject, QSettings, Qt
from PySide6.QtGui import QColor, QPalette
from qfluentwidgets import Theme, setTheme, setThemeColor
# Fluent widgets restyle themselves on setTheme(); QMainWindow, the native
# QTabWidget/QStatusBar and the Windows title bar keep the OS palette unless
# the palette, an app stylesheet and the DWM title bar attributes follow suit
# (same approach as DLPilot app_fluent.py).
_SURFACES = {
False: {"window": "#f4f6f8", "base": "#ffffff", "text": "#1f2328",
"dim": "#57606a", "border": "#d8dee4", "selected_text": "#ffffff"},
True: {"window": "#202020", "base": "#2b2b2b", "text": "#f5f5f5",
"dim": "#9a9a9a", "border": "#3a3a3a", "selected_text": "#1f2328"},
}
class ThemeManager(QObject):
"""Keep the appearance choice persistent and synchronized with Qt."""
MODES = {"system", "light", "dark", "dark1", "dark2"}
_THEMES = {
"system": Theme.AUTO,
"light": Theme.LIGHT,
"dark": Theme.DARK,
"dark1": Theme.DARK,
"dark2": Theme.DARK,
}
_ACCENTS = {
"light": "#0f6cbd",
"dark": "#f2c94c",
"dark1": "#5aa9e6",
"dark2": "#35c6c8",
}
def __init__(self, app):
super().__init__(app)
self.app = app
self.window = None
self.settings = QSettings("LT8619C-Tools", "EDID-Viewer")
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))
+44
View File
@@ -0,0 +1,44 @@
"""Read active monitors through Windows WMI; never change display settings."""
import json
import subprocess
SCRIPT = r'''
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
$connections = @{}
try { Get-CimInstance -Namespace root/wmi -ClassName WmiMonitorConnectionParams | ForEach-Object { $connections[$_.InstanceName] = [long]$_.VideoOutputTechnology } } catch {}
$result = @(Get-CimInstance -Namespace root/wmi -ClassName WmiMonitorDescriptorMethods | Where-Object Active | ForEach-Object {
$m = $_
$bytes = [System.Collections.Generic.List[byte]]::new()
$failure = ''
try {
$base = Invoke-CimMethod -InputObject $m -MethodName WmiGetMonitorRawEEdidV1Block -Arguments @{BlockId=[byte]0}
if (-not $base.ReturnValue -or $base.BlockContent.Count -ne 128) { throw 'Base EDID read failed' }
$bytes.AddRange([byte[]]$base.BlockContent)
for ($i=1; $i -le [int]$base.BlockContent[126]; $i++) {
$ext = Invoke-CimMethod -InputObject $m -MethodName WmiGetMonitorRawEEdidV1Block -Arguments @{BlockId=[byte]$i}
if (-not $ext.ReturnValue -or $ext.BlockContent.Count -ne 128) { throw "Extension $i read failed" }
$bytes.AddRange([byte[]]$ext.BlockContent)
}
} catch { $failure = $_.Exception.Message }
[pscustomobject]@{instance=$m.InstanceName; connection=$connections[$m.InstanceName]; hex=([BitConverter]::ToString($bytes.ToArray())).Replace('-',''); error=$failure}
})
ConvertTo-Json -InputObject $result -Depth 4 -Compress
'''
CONNECTIONS = {0:'VGA',4:'DVI',5:'HDMI',6:'LVDS 内屏',10:'DisplayPort',11:'eDP 内屏',15:'Miracast',16:'间接有线显示',2147483648:'内部显示'}
def scan():
import base64
command = base64.b64encode(SCRIPT.encode('utf-16le')).decode('ascii')
proc = subprocess.run(['powershell.exe','-NoProfile','-NonInteractive','-EncodedCommand',command],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=45,
creationflags=subprocess.CREATE_NO_WINDOW)
if proc.returncode:
raise RuntimeError(proc.stderr.decode('utf-8','replace').strip())
data = json.loads(proc.stdout.decode('utf-8-sig'))
for item in data:
item['raw'] = bytes.fromhex(item.pop('hex'))
item['连接'] = CONNECTIONS.get(item['connection'],f'未知 ({item["connection"]})')
return data
+3
View File
@@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0"
start "" ".venv\Scripts\pythonw.exe" "src\main.py"
+48
View File
@@ -0,0 +1,48 @@
"""Headless check for the About button and dialog: saves header and dialog PNGs."""
import sys
from pathlib import Path
from PySide6.QtCore import QTimer
from PySide6.QtGui import QFont
from PySide6.QtWidgets import QApplication, QMessageBox
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
import main as m
OUT = Path(__file__).resolve().parent
def snap_dialog():
boxes = [w for w in QApplication.topLevelWidgets() if isinstance(w, QMessageBox)]
if not boxes:
QTimer.singleShot(200, snap_dialog)
return
boxes[0].grab().save(str(OUT / "about-dialog.png"))
boxes[0].close()
def open_about(window):
QTimer.singleShot(600, lambda: snap_dialog())
window.show_about()
def main():
app = QApplication(sys.argv)
app.setApplicationName(m.APP_NAME)
app.setFont(QFont("Microsoft YaHei UI", 10))
theme_manager = m.ThemeManager(app)
window = m.Window(theme_manager)
window.show()
def grab_header():
window.grab().save(str(OUT / "about-header.png"))
open_about(window)
QTimer.singleShot(1500, grab_header)
QTimer.singleShot(6000, app.quit)
sys.exit(app.exec())
if __name__ == "__main__":
main()
+30
View File
@@ -0,0 +1,30 @@
param(
[string]$ProcessName = "python",
[string]$OutPng = "$env:TEMP\edid-window.png"
)
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class Win32Cap {
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
public struct RECT { public int Left, Top, Right, Bottom; }
}
"@
Add-Type -AssemblyName System.Drawing
$proc = Get-Process -Name $ProcessName -ErrorAction Stop |
Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like 'EDID*' } |
Select-Object -First 1
if (-not $proc) { throw "no EDID window found for process '$ProcessName'" }
$hwnd = $proc.MainWindowHandle
[Win32Cap]::SetForegroundWindow($hwnd) | Out-Null
Start-Sleep -Milliseconds 800
$r = New-Object Win32Cap+RECT
[Win32Cap]::GetWindowRect($hwnd, [ref]$r) | Out-Null
$w = $r.Right - $r.Left; $h2 = $r.Bottom - $r.Top
$bmp = New-Object System.Drawing.Bitmap($w, $h2)
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.CopyFromScreen($r.Left, $r.Top, 0, 0, (New-Object System.Drawing.Size($w, $h2)))
$bmp.Save($OutPng, [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
Write-Output "saved $OutPng ($w x $h2)"
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
{
"instance": "DISPLAY\\SDC420B\\4&1387f127&0&UID8388688_0",
"connection": 2147483648,
"error": "常规故障 ",
"连接": "内部显示",
"基本信息": {
"厂家 PNP 代码": "SDC",
"产品编号": "0x420B",
"数字序列号": 0,
"EDID 版本": "1.4",
"生产日期": "2024 年 / 周未指定",
"尺寸": "34 × 22 cm",
"Gamma": 2.2,
"扩展块数": 2,
"SHA-256": "16f168eda782b8d49c3ef5cdf46cb894c353c6ef2570629707b72a0cdefb9584",
"输入类型": "数字",
"接口声明": "DisplayPort",
"色深 bpc": 10,
"频率范围原始值": "垂直 48–240 Hz;水平 177–177 kHz;最大像素时钟 1170 MHz",
"描述文本": "SDC",
"显示器名称": "ATNA60HU01-0"
},
"时序": [],
"能力": [
"色度坐标:Rx=0.6807, Ry=0.3184, Gx=0.2197, Gy=0.7246, Bx=0.1426, By=0.0391, Wx=0.3135, Wy=0.3291"
],
"数据块": [
{
"块": 1,
"类型": "DisplayID(暂未解码)",
"原始": "70 20 79 02 00 20 00 0c ba 41 59 0b 42 00 00 00 00 00 18 00 21 00 1d 74 0d 69 08 00 0a 40 06 00 e5 7a 51 85 a3 b9 4a 02 0a 02 45 54 d0 5f 4c 64 00 45 23 78 26 00 09 06 00 00 00 00 00 50 00 00 22 00 28 80 c5 11 85 ff 09 87 00 07 00 1f 00 3f 06 c7 00 7f 00 07 00 80 c5 11 05 ff 09 87 00 07 00 1f 00 3f 06 df 15 97 15 07 00 81 00 0b e3 05 80 00 e6 06 05 01 8f 6a 02 00 00 00 00 00 f1 90"
}
],
"校验": [
"块 0: 通过",
"块 1: 通过"
],
"警告": [
"声明 384 字节,实际 256 字节",
"范围描述符有扩展偏移,频率范围显示原始字段,暂未应用偏移"
]
}
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x4C, 0x2D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x04, 0xA2, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x3A, 0x80, 0x18, 0x71, 0x38, 0x2D, 0x40, 0x58, 0x2C, 0x45, 0x00, 0xFD, 0x1E, 0x11, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0xFC, 0x00, 0x54, 0x45, 0x53, 0x54, 0x20, 0x50, 0x41, 0x4E, 0x45, 0x4C, 0x0A, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xC2, 0x02, 0x03, 0x17, 0x70, 0x42, 0x90, 0x61, 0x23, 0x09, 0x07, 0x07, 0x67, 0x03, 0x0C, 0x00, 0x12, 0x34, 0x30, 0x78, 0xE3, 0x06, 0x0C, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xAD
+9
View File
@@ -1,5 +1,14 @@
# I2C-Inject — LT8619C 寄存器择机注入工具
> **⏸ 状态:暂停开发(2026-09-25),后续开发并入 I2C-Lab(2026-10-02)。**
> 工具与固件保留在仓库中不作删除;本目录转为留档。完整的 I2C 调试台
> (纯被动监控 → 主机读写/择机注入 → 从机模拟)在 [`I2C-Lab/`](../I2C-Lab/)
> 继续:监控阶段 v0.1 已就绪(PIO 硬件捕获 + 实时事务流 + 波形快照),
> 本工具的"择机注入"职能将在其主机阶段复活(接线 GP8/SDA、GP9/SCL 与
> `=`/`!` 回显约定不变)。
> demo 板寄存器语义实测当时即由本工具完成;0x606E 其余位交换经确认(2026-09-25)
> 现状正常、符合预期,无需单独实测。恢复开发前请勿视为完整可用。
> 更新:2026-09-03。由两部分组成:**RP2040 注入器固件**(`mcu-rp2040/`,刷入
> BPI-Pico-RP2040,MicroPython)+ **PC 端 GUI**(`i2c_inject_gui.py` / 打包版
> `I2C-Inject.exe`)。可在 LT8619C demo 板的 I2C 总线上安全地在线读写寄存器。
+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'
+61 -21
View File
@@ -1,53 +1,93 @@
# LT8619C_Debug_Tools
# HW-Debug-Tools
LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套上位机工具集。
硬件调试上位机工具集,起家于 LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套,
现收纳全部调试工具;原名 `LT8619C_Debug_Tools`,2026-09-28 更名(Gitea 旧地址自动重定向)。
上游主仓库(固件):[LT8619C_DLPC3421_HDMI](https://gitea.hulk.wang/hulk/LT8619C_DLPC3421_HDMI)。
## 工具一览
| 工具 | 用途 | 获取 |
|---|---|---|
| [DLPilot](DLPilot/) | HDMI 源端(EDID 查看/逐字节解析、分辨率切换、纯色与测试图案全屏投屏)+ 光机控制(RS485→GD32 网关→DLPC3421:开关光机/设光强/查温度电流) | 到 [Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases) 下 `DLPilot-vX.Y.Z-win64.zip`,解压即用;详见 [DLPilot/README.md](DLPilot/README.md) |
| [I2C-Inject](I2C-Inject/) | LT8619C 寄存器在线读写:RP2040 注入器在 demo 板 I2C 总线上择机注入(避让主控轮询,写后自动读回校验) | 需配合 RP2040 固件 `rp2040_inject.py`;详见 [I2C-Inject/README.md](I2C-Inject/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="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-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) |
## 环境要求
- 发布版 zip 开箱即用(Windows 10/11 x64,无需安装 Python)
- 源码运行:Python ≥3.10;DLPilot 命令行/EDID 纯标准库,GUI 需
`py -m pip install customtkinter`(自定义图片 JPG/BMP 可选 Pillow、光机控制可选 pyserial);
I2C-Inject 需 `py -m pip install pyserial`
- 重新打包:`DLPilot/src/build_exe.py`(自动安装 PyInstaller);I2C-Inject 目录内 `python build_exe.py`
`py -m pip install PySide6-Fluent-Widgets`(自定义图片 JPG/BMP 可选 Pillow、光机控制可选 pyserial);
EDID-viewer GUI 需 `py -m pip install -r EDID-viewer/requirements.txt`(PySide6 + PySide6-Fluent-Widgets);
SerialMate GUI 需 `py -m pip install PySide6 PySide6-Fluent-Widgets pyserial`;
I2C-Lab GUI 同上;重新构建 probe 固件需 pico-sdk 2.x + Arm GNU 交叉工具链
(见 I2C-Lab/AGENTS.md)
- 重新打包:`DLPilot/src/build_exe.py`、`EDID-viewer/src/build_exe.py` 与
`SerialMate/src/build_exe.py`、`I2C-Lab/src/build_exe.py`(均自动安装 PyInstaller);
I2C-Inject 目录内 `python build_exe.py`
## 目录结构
每个工具内统一三区:**src/ 源代码(人写)** → **assets/ 生成资产(可再生成)** →
**release/ 打包与发布产物(机器生成)**;README.md 面向使用者,AGENTS.md 面向 AI/Agent(工程约定与构建规程)。
**release/ 打包与发布产物(机器生成)**;README.md 面向使用者,AGENTS.md 记录工程约束与开发进度,
仓库整体的开发需求与进度管理见根目录 `agent.md`。
```
LT8619C_Debug_Tools/
HW-Debug-Tools/
├── DLPilot/
│ ├── src/ # 源码 + 打包/发布脚本 + 图标源
│ ├── assets/ # 生成的测试图案 PNG(gen_*.py 产物)
│ ├── release/ # 打包 exe + 运行配置(入库);发布 zip(不入库)
│ ├── release/ # 交付物(exe/zip 不入库,本地保留;只上传 Gitea Release 附件)
│ ├── README.md # 使用说明
│ └── AGENTS.md # AI/Agent 工程档案
├── I2C-Inject/ # PC 端 GUI + RP2040 固件 + 打包 exe + 图标 + README
│ └── AGENTS.md # 工程档案
├── EDID-viewer/
│ ├── src/ # 界面/解析/WMI 读取 + 打包/发布脚本
│ ├── assets/ # 图标与生成脚本
│ ├── release/ # 交付物(exe/zip 不入库,本地保留;只上传 Gitea Release 附件)
│ ├── README.md # 使用说明
│ └── AGENTS.md # 工程档案
├── SerialMate/
│ ├── src/ # 协议/串口线程/曲线控件/GUI + 自检 + 打包/发布脚本
│ ├── release/ # 交付物(exe/zip 不入库,本地保留;只上传 Gitea Release 附件)
│ ├── README.md # 使用说明
│ └── AGENTS.md # 工程档案
├── 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
└── README.md
```
## 发布流程(DLPilot)
## 发布流程(DLPilot / EDID-Viewer / SerialMate / I2C-Lab 同一套流水线)
```powershell
cd DLPilot/src
python build_exe.py # 打包 → ../release/DLPilot.exe
python package_release.py <版本> # → ../release/DLPilot-v<版本>-win64.zip
python publish_gitea.py DLPilot-v<版本>-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN)
cd DLPilot/src # 或 EDID-viewer/src、SerialMate/src、I2C-Lab/src
python build_exe.py # 打包 → ../release/<工具>.exe
python package_release.py <版本> # → ../release/<工具>-v<版本>-win64.zip
python publish_gitea.py <工具>-v<版本>-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN)
```
每个工具独立打标签发布(`DLPilot-v*` / `EDID-Viewer-v*` / `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 板逻辑分析仪
双色交叉实测,过程数据与规程存于上游主仓库 `doc/architecture/`
- 更新:2026-09-11
- 全部工具图标(logo 家族:黑底 + 工具色圆角块 + 两行字)的约定与再生成见
[icons/README.md](icons/README.md),统一生成器 `icons/make_icon.py`
- 更新:2026-10-10
+293
View File
@@ -0,0 +1,293 @@
# AGENTS.md — SerialMate 工程档案(开发约束与进度)
> 人类文档 [README.md](README.md) 回答"这是什么、怎么用";本文记录"代码怎么组织、
> 怎么构建发布、改动时哪些约束不能破坏",兼作本工具的进度记录。
> 修改本目录代码前先读完本文。仓库整体的开发需求与进度管理见根目录 `agent.md`。
## 1. 项目定位
仓库 `HW-Debug-Tools` 是 LT8619C→DLPC3421 投影转接板的调试上位机工具集。
SerialMate 是其中的**通用串口/RS485 调试助手**:收发 + 协议解析 + 曲线。
默认帧格式与 DLPilot 光机 RS485 协议完全一致(`D5 01 LEN <payload> CRC`),
可直接调试板载 GD32E230 网关;帧格式(包头/板ID/CRC 算法与覆盖范围)可定制,
适配任意"包头+板ID+LEN(=payload长度)+payload+CRC"五段式协议。
协议语义权威同 DLPilot:上游固件仓 `protocols.md`,本仓不维护副本。
## 2. 目录契约
```
SerialMate/
├── src/ 源代码区 —— 只有这里可以手改
├── release/ 产物区 —— SerialMate.exe + README.md(exe 不入库本地保留,README 入库),
│ *-win64.zip(gitignore,只上传 Release)
├── assets/ 图标等生成资产(暂无;build_exe.py 已预留 assets/serial-mate.ico)
├── README.md 人类文档
└── AGENTS.md 本文
```
### 文件角色速查
| 文件 | 角色 |
|---|---|
| `src/main.py` | Fluent GUI 入口(含 `--seconds N` 冒烟参数),argparse 仅此一处 |
| `src/protocol.py` | 协议模块(纯标准库):FrameLayout 帧构成令牌、ProtoConfig 组帧/校验、FrameParser 增量解析、CRC 算法表、extract_value 数值提取 |
| `src/link.py` | 串口工作线程 SerialLink(pyserial 只准在这里 import),支持 `loop://` 虚拟回环 |
| `src/plot.py` | 滚动曲线控件(纯 QPainter,刻意不引 matplotlib/pyqtgraph) |
| `src/config.py` | BASE_DIR 定位与 serial_mate.json / board_profiles.json 读写(缺键补默认,读容错) |
| `src/theme.py` | 主题三件套同步(Fluent setTheme + QPalette/QSS + DWM 标题栏),与 EDID-Viewer 同源 |
| `src/selftest.py` | 协议自检(无依赖、无硬件,改 protocol.py 后必跑) |
| `src/selftest_loop.py` | 链路自检(loop:// 回环验证 组帧→发送→接收→解析→提取,缺 pyserial 跳过) |
| `src/selftest_gui.py` | GUI 功能自检(offscreen 驱动事件处理函数:预览/回显/提取/配置往返,改 main.py 后建议重跑) |
| `src/build_exe.py` | PyInstaller onefile 打包 → `release/SerialMate.exe` |
| `src/package_release.py` | exe+README → `release/SerialMate-v<版本>-win64.zip` |
| `src/publish_gitea.py` | 建/复用 Gitea Release 并上传 zip(`GITEA_TOKEN`,需 `--insecure`) |
## 3. 代码不变量(改代码必须维持)
1. **依赖分层**:protocol.py / config.py 纯标准库;pyserial 只准在 link.py
import(main.py 经 `SerialLink` 间接使用);plot.py 只依赖 PySide6;
GUI 硬依赖 PySide6-Fluent-Widgets + pyserial(串口工具缺 pyserial 即无意义,
与 DLPilot 的"缺失降级"策略不同,只保留 try-import 提示)。
1a. **卡片布局约定**:`_card(side=True)` 把短副标题放标题右侧省一行(串口/
协议/发送卡);右列是纵向 QSplitter(接收/发送高度用户可拖拽,拉伸余量
默认给接收、发送卡保持自然高度防空腔);发送卡末尾 `lay.addStretch(1)`
保证内容贴顶(卡片被拖高后富余高度收在卡底,勿删——否则拉伸被各行均分,
标题上方出现空隙);发送卡行序:板型管理行(下拉+新建+删除+配置+重载) /
预设行(下拉+填入+直接发送) / 模式行(下拉+存为板型+存入预设) / 输入行 /
周期行;帧日志 payload 高亮走 `_append_rx_frame`(`_esc_log` 转义后拼 span
背景,toPlainText 内容不变,selftest 的日志断言因此不受影响);预设下拉
显示"名称 · 命令",存入命令走 `_add_command_named`(弹窗核 `_add_command`
包装,测试用核函数),预设直发走 `_send_preset_direct`(经输入框复用
组帧/历史链路)。
1b. **窗口最小高度**:首帧布局后 `_apply_minimum_size` 按内容实际最小高度
抬高窗口下限(高度拉过矮时布局约束无法满足,曲线卡绘图区会被压塌并与
通道行互相叠压——用户实测);勿删, 调整曲线卡行数/高度后该值自动跟随。
2. **回调线程边界**:SerialLink 的 on_status/on_frame/on_raw/on_tx 全在后台线程
触发,一律 `events.put(...)` 投递,主线程 30ms QTimer 轮询 `_drain_events`
分发(与 DLPilot `_poll_dlpc` 同模式),禁止回调里直接碰 GUI。
**时间戳由 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 间隔),
RS485 总线多帧连发留应答间隙,勿改成批量 drain 一次写。
4. **帧构成语义**:帧由 `FrameLayout` 令牌序列定义(`HDR ID LEN PAYLOAD CRC`
令牌 + 十六进制字面量,收发共用布局、仅包头值分方向);约束:LEN/PAYLOAD/CRC
恰好各一次、CRC 必须最后、LEN 前至少一个 HDR 或字面量(同步定界)、ID 最多一次。
LEN 口径(`LEN_SEMS`):payload 长度 / 到帧尾(payload+CRC) / 整帧;
CRC 算法/覆盖范围见 protocol.CRC_ALGOS/CRC_SCOPES,覆盖按**帧内位置**定义
(with_header=第1字节起 / no_header=跳过第1字节 / payload_only=仅载荷),
任意布局通用;2 字节 CRC 线上字节序 MODBUS 低前、其余高前,按算法约定单序
校验(改动需同步 selftest 向量)。
5. **解析重同步策略**:前缀令牌逐位对齐(HDR/字面量须匹配、ID 通配),LEN 值经
口径换算为 payload 长度,负值视为伪帧头逐出;CRC 失配丢 1 字节重同步——
通用工具不预设合法 LEN 区间(与 DLPilot 的 LEN 范围预判不同)。
6. **ID 位不校验只透出**:`Frame.code` 原样上抛(布局无 ID 时为 None,GUI 日志
与 selftest 均需兼容 None),TYPE_NAMES 仅用于显示译码(DLPilot 语义)。
7. **BASE_DIR 语义**(config.py):frozen 时 =exe 所在目录,源码运行时 =src/;
设置平铺存 `serial_mate.json`,不建子目录。`tx.periodic` 不随配置
自动启动(防误连发),其余配置启动恢复。
8. **曲线数据流**:每帧对每个启用通道调 `extract_value`(失败静默跳过),
`PlotWidget.add` 内部 deque(maxlen=3000) 滚动丢弃;数值格式统一
`fmt_val`(4 位有效数字)。
9. **主题**:theme.py 三件套同步(见 EDID-Viewer 教训:只 setTheme 会黑块);
plot 曲线颜色固定 4 色,文字/网格色取自 QPalette 自动跟随明暗。
9a. **曲线时间窗**:PlotWidget 持有 `_span`(窗宽秒, None=跟随全部) 与
`_offset`(右端距最新秒数, 0=实时);横轴域换算在 `visible_domain`(纯计算,
selftest 有断言)。**窗口不向数据收敛**(用户实测反馈):选 5min 而数据只有
23s 时显示完整 5min 轴、曲线贴右端随采集填满(示波器式),右下角标注
"已采集 Xs / 窗口 Ns";滞后超出全部历史 = 空窗保宽。弹窗内 时间窗下拉+
滑条 只改这两个状态;滑条 0..1000 线性映射 (1000-v)/1000×maxpan,实时
跟随=拉到最右(1000);poll 定时器 `_sync_time_slider` 回写位置(用户拖动中
跳过);嵌回卡片时强制重置为跟随全部(卡内无滑条, 防困在滞后窗里)。纵轴
数值刻度画在每条横向参考线上。
悬停读值:mouseTracking 开启, mouseMove 只记录 `_hover_pos` 并请求重绘;
读值内容由 paintEvent **每帧**经 `_hover_lines_at(_hover_pos)` 重算(光标
时间 T 经 `_plot_geometry` 换算, `_nearest` 二分取 ±10px 容差内最近采样),
画在顶部居中反色小框(`_hover_box`)——鼠标停住不动时数值也随实时数据持续
刷新(勿改回 mouseMove 里一次性算好/或 QToolTip, 那样不动鼠标就不更新——
用户实测反馈)。`_nearest`/`_hover_lines_at` 有单测。绘图区内边距统一用
`M_LEFT/TOP/RIGHT/BOTTOM` 类常量(绘制与鼠标换算共用, 勿在 paint 里另写)。
9b. **暂停(两级语义)**:主界面"暂停"= `_paused`, add() 丢弃暂停期间的数据;
弹窗"暂停渲染"= `_frozen`, 数据照常入库但跳过重绘——冻结是**绝对状态**:
PlotWidget 覆写 `update()` 直接吞掉一切重绘请求(任何代码路径都无法在
冻结期打破), 只有 `set_frozen(False)` 恢复并一次性渲染积累;冻结时画面
中央显示"已冻结渲染(采集继续)"角标让状态可见。两者互不干扰, 均有
selftest 断言。
9c. **弹窗曲线交互**:自动Y(勾选按钮, 与主界面 chk_auto_y 双向同步, 关闭时
以 `auto_yrange()` 初始化手动量程);图上左键拖动 = 平移手动 Y 量程
(`on_yrange_changed` 回调同步主界面 Ymin/Ymax 输入框, 阻断信号防回环);
滚轮 = 缩放时间窗 2~900s(滞后按比例缩放, 经 `on_span_changed` 回调让
`_sync_time_slider` 把时间窗下拉同步为匹配档位/自定义(-1));"实时"按钮
= offset 归零跳回最新。自动 Y 量程抽取为 `auto_yrange()`(纯计算, 有单测)。
11a. **曲线弹出窗口**:绘图区在曲线卡留有固定槽位布局 `_plot_slot`,弹出 =
`setParent(None)` 后装入顶层窗口(通道/暂停/Y 轴控件留在主界面控制同一
PlotWidget 实例);关掉弹出窗口(eventFilter 捕 Close)或主窗 closeEvent
都会走 `_dock_plot()` 嵌回 —— `_dock_plot` 必须先摘 `self._plot_win` 引用
再 `win.close()`(防 Close 递归),且 `setParent(None)` 后必须补 `show()`
(重挂布局不会自动恢复可见)。
10. **多实例**:有意不加单实例锁(双口双开的合法场景),串口冲突靠
pyserial 报错提示("串口被占用"文案在 link.py,勿删)。
11. **板型档案**:`board_profiles.json`(BASE_DIR 平铺,用户本机数据不入库,
首次运行自动生成默认"DLPilot光机")。档案 = name + proto(7 个帧协议字段,
**不含 parse** —— 解析开关是查看偏好) + commands[[名,命令]..];切换板型
应用协议参数并装载命令表,"存入"命令以命令文本为名去重;启动恢复选中
板型时**只装载命令表不重复应用协议参数**(协议以上次会话 store 为准);
GUI 增删后经 config.save_profiles 落盘,"配置/重载"支持手改 JSON。
## 4. 构建与发布(全部在 src/ 下执行)
```powershell
python -m compileall -q . # 全模块可编译
python selftest.py # 协议回归(47 项, 改 protocol.py 后必跑)
python selftest_loop.py # 链路回环(需 pyserial, 改 link.py 后必跑)
python selftest_gui.py # GUI 功能回归(offscreen, 改 main.py 后建议重跑)
python main.py --seconds 5 # GUI 冒烟(源码); offscreen: set QT_QPA_PLATFORM=offscreen
python build_exe.py # → ../release/SerialMate.exe(缺 PyInstaller 自动装)
python package_release.py <版本> # → ../release/SerialMate-v<版本>-win64.zip
python publish_gitea.py ../release/SerialMate-v<版本>-win64.zip --replace --insecure
```
发布约定与 Gitea 实例怪癖(TLS 抽风加 `--insecure`、assets 两种形状兼容、
token 读 `GITEA_TOKEN` 用户级环境变量)同 DLPilot,详见 `DLPilot/AGENTS.md` §5;
tag/release 命名 `SerialMate-vX.Y.Z`,zip 命名
`SerialMate-vX.Y.Z-win64.zip`(publish_gitea.py 从文件名推 tag)。
## 5. 已知坑(本工具踩过/继承的)
- `qfluentwidgets.CaptionLabel` 无 `setDim` 方法(本机版本),灰字用
`CaptionLabel` 本身即可;`ComboBox` 不要用 `currentData`(qfluentwidgets
自绘下拉 userData 支持不稳),一律 currentIndex + 平行元组映射。
- **qfluentwidgets 控件默认水平 Expanding**:紧凑行不设 stretch 项时空白会被
CheckBox/Label 等瓜分出大空隙 —— 一律走 `Window._row()`(行尾默认补
stretch;Qt 规则:有 stretch 因子时额外空间只按因子分配),需要自管拉伸的
行传 `trailing=False`。
- **qfluentwidgets SpinBox/DoubleSpinBox 设固定宽后内部行编辑器会塌缩**
(minimumSizeHint 142/193 只是参考,强设小于它的宽度时行编辑器只剩几像素,
数值被裁成残字——整数 0 在 96px 时渲染成 "]"):整数 SpinBox ≥110px、
DoubleSpinBox ≥130px 才能完整显示数值;曲线卡偏移/序号/倍率/Y 量程框的
宽度有 selftest 断言把守, 调布局时勿低于阈值。
- 组帧/解析回路自测时必须让 `tx_hdr == rx_hdr`(收发方向包头本就允许不同,
D5 组的帧 B5 解析器看不见——selftest 的 fuzz/回路用例已按此约定)。
- offscreen 冒烟报 `QFontDatabase: Cannot find font directory` 属 Qt offscreen
平台噪音,桌面运行无此问题,勿当 bug 修。
- onefile 运行时定位 exe 用 `sys.executable`(`__file__` 在 `_MEIPASS`),
config.py 已按此实现。
## 6. 验证清单(改完代码后)
1. `python -m compileall -q .` —— 全模块可编译;
2. `python selftest.py` —— 协议回归(改 protocol.py 后必跑);
3. `python selftest_loop.py` —— 链路回环(改 link.py 后必跑);
4. `python main.py --seconds 5` —— GUI 冒烟自动开关;
5. `python build_exe.py` → 双击 release/SerialMate.exe 确认界面渲染;
6. (有硬件时)USB-RS485 接光机发 M1/M201/M731 验证真机收发与曲线。
## 7. 进度记录
### v0.1.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,首版)
已完成:
- 协议模块:**帧构成令牌序列可定制**(`HDR ID LEN PAYLOAD CRC` 令牌 + 十六进制
字面量同步字节,收发共用布局,支持字段换序/无板ID/多字节同步头;LEN 口径三种:
payload 长度 / 到帧尾 / 整帧)、5 种 CRC 算法 × 3 种覆盖范围(按帧内位置定义,
任意布局通用)、增量解析(前缀逐位对齐 + CRC 失败逐字节重同步)、
二进制/文本/正则三种曲线数值提取;自测 64 项全 PASS(含 DLPilot 实测帧向量、
CRC 标准校验值 check("123456789")、布局换序/字面量/LEN 口径向量与布局模糊往返)。
- 串口链路:参数热切换、发送队列(半双工友好 + 未连接丢弃)、解析/透传双模式
热切、`loop://` 虚拟回环自检 13 项全 PASS(组帧→发送→接收→解析→曲线提取)。
- GUI(PySide6-Fluent-Widgets):串口/协议/曲线/接收/发送五卡,帧构成与 LEN 口径
可视化编辑(非法输入即时提示),payload 字符串/HEX 切换(帧日志的 payload
段垫半透明底色高亮——主题无关 rgba, 明暗切换不残留, 经 appendHtml 实现)、
原始帧 hex、时间戳、日志保存;发送:自动包装(`/` 分隔多条)、HEX 直发、
周期发送、组帧实时预览、历史回溯(↑↓)、回车发送;主题跟随系统。
- 曲线(plot.py + 弹窗,2026-09-27 实机反馈多轮迭代):4 通道三种提取方式 +
倍率,自动/手动 Y 量程;绘图区可**弹出独立窗口**(嵌回自动重置视图);
纵轴每条参考线标数值刻度;时间窗(跟随/10s~15min, 示波器式不向数据收敛,
右下角"已采集/窗口"标注)+ 横向滑条回看历史;悬停读值框固定顶部、鼠标
不动也随数据持续刷新;**暂停渲染**(冻结显示继续收数, 与主界面"暂停"
两级语义, 冻结有中央角标);自动Y 开关、图上拖动自定义 Y 轴(同步主界面
输入框)、滚轮缩放时间窗(2~900s)、实时按钮;窗口最小高度按内容自适应
(防高度拉小时通道行叠压绘图区)。
- 板型档案:发送卡"板型"下拉一键切换协议参数 + 命令预设;**新建板型**(协议
沿用当前、命令预设空白,防遗留其他板子的命令)/存为板型(同名覆盖)/
**删除板型**(确认弹窗,至少保留一个,删除后切第一个板型且协议参数不动)/
存入预设命令(弹窗自定义显示名称)/配置=内嵌表格编辑器(**CommandTable**:
第 0 列 ≡ 手柄按住上下拖动整行排序——内部 QDrag,move_row 整行搬移;
网格线/隔行底色按 palette 明暗自适配,空行清晰可见;+加行 −删行;空命令与
重复命令保存时忽略/去重,写回 board_profiles.json)/重载;预设下拉
"名称 · 命令"同显。
- 发布流水线:build_exe/package_release/publish_gitea 与全仓一致;exe 已重建
入库,zip/Release 未发布(待用户验收)。
待办 / 计划:
- 【无图标】assets/serial-mate.ico 未生成(build_exe.py 已预留路径,
参照 DLPilot logos/make_ico.py 流程补)。
- 【真机已过】用户已实机联调(COM15 真机收发/曲线提取/多板型档案实际使用,
2026-09-27 多轮界面反馈即来自真机数据);温度曲线量纲(倍率取值)待用户
按传感器手册确认。
- 【计划】发送区多条 payload 的"批处理脚本"化(延时/循环/条件等待应答)。
- 【计划】曲线数据导出 CSV;接收日志按日期自动分文件。
- 【考虑】CRC 解析侧双字节序兼容开关(当前按算法约定单序)。
+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 卡顿成批挤压、显示延迟随运行时间增大;
修复后时间戳单调且间隔≈设定周期,长时间运行无卡顿增长。
+133
View File
@@ -0,0 +1,133 @@
# SerialMate — RS485/串口调试助手
> LT8619C 调试工具集配套工具:串口收发 + 协议包解析 + 曲线绘制。
> 帧格式(包头/板ID/LEN/CRC)全可定制,默认与 [DLPilot](../DLPilot) 光机
> RS485 协议一致,可用来直接调试板载 GD32E230 网关或任意同构协议设备。
## 功能
- **串口收发**:端口自动枚举、波特率 1200~921600、数据位/校验/停止位可选,
参数热切换;半双工 RS485 友好(每周期最多写一帧,留应答间隙)。
- **协议解析**(可关,关=原始透传):
增量字节流定界 + CRC 校验,失败逐字节重同步,噪声自动丢弃;
接收帧 ID 位按 DLPilot 语义译码显示(0xF0=成功、0xFD=参数越界…)。
- **帧构成定制**(协议卡,发送/接收共用同一布局,仅包头值分方向):
帧构成用**令牌序列**定义,默认 `HDR ID LEN PAYLOAD CRC`(即 DLPilot 五段式):
| 令牌 | 含义 |
|---|---|
| `HDR` | 包头(值取 TX包头 / RX包头 字段) |
| `ID` | 板ID(RX 侧多为响应 TYPE 码,不校验仅透出显示) |
| `LEN` | 长度字节,口径可选:**payload 长度(默认)** / 到帧尾(payload+CRC) / 整帧 |
| `PAYLOAD` | 协议载荷(发送框内容,别名 DATA) |
| `CRC` | 校验(算法/覆盖范围见下表,必须在末尾) |
| 十六进制字面量 | 固定字节,如 `AA 55` 双字节同步头 |
示例:`AA 55 LEN PAYLOAD CRC`(AA55 同步头、无板ID)·
`HDR LEN PAYLOAD ID CRC`(板ID 在载荷后)· `HDR LEN ID PAYLOAD CRC`(板ID 在长度后)。
约束:LEN/PAYLOAD/CRC 恰好各一次,LEN 前至少一个 HDR 或字面量作同步头。
| 项 | 说明 | 默认 |
|---|---|---|
| TX 包头 | HDR 令牌在命令帧的值 | `D5` |
| RX 包头 | HDR 令牌在响应帧的值 | `B5` |
| 板ID | ID 令牌的值(TX 用;RX 侧仅透出) | `01` |
| LEN 口径 | payload 长度 / 到帧尾(payload+CRC) / 整帧 | payload 长度 |
| CRC 算法 | SUM8 累加和 / CRC8 / CRC16 MODBUS / CRC16 XMODEM / CRC16 CCITT-FALSE | SUM8 |
| CRC 覆盖 | 含包头(整帧) / 不含包头(跳过第1字节) / 仅 payload | 不含包头 |
- **接收显示**:payload 一键切换 **字符串 / HEX**;可选附原始帧 hex;
时间戳为**字节到达时刻**(串口后台线程打点随帧携带,UI 繁忙时日志时间
依然可信);日志保存为 txt。接收日志自动治理:行数上限 5000(超量淘汰
最旧)、批量刷新、事件积压时高频帧日志自动折叠为计数摘要(曲线照常
采集)——长时间高频运行不累积卡顿。
- **曲线**:4 通道,每帧有效数据按通道提取一个数值,支持三种提取方式
(二进制定长 u8~f32 大小端 / 文本第 N 个数字 / 自定义正则)+ 倍率,
y 轴自动或手动量程,暂停/清空;绘图区可**弹成独立窗口**("弹出"按钮,
关掉窗口即嵌回),通道配置与暂停等控件留在主界面照常控制。弹出窗口内:
纵轴每条参考线标注数值刻度;**时间窗**可选宽度(跟随/10s/30s/1min/5min/
15min),配合横向滑条回看历史总体趋势或缩小窗口细看局部走势,拉到最右
恢复实时跟随(冻结查看请配合"暂停");窗口宽度不向数据收敛——数据不足时
显示完整的所选时间轴、曲线贴右端随采集逐渐填满(示波器式),右下角标注
"已采集 Xs / 窗口 Ns";**鼠标悬停**在曲线上,绘图区顶部的小读数框持续显示
光标处时间点 T 与各通道在该时刻的 Y 轴数值(±10px 容差取最近采样);
弹出窗口工具栏:**暂停渲染**(只冻结画面方便查看,采集继续入库,点"继续
渲染"后把积累的数据一次性画出来;画面中央会显示"已冻结渲染"角标;注意
与主界面"暂停"的区别:那个会丢弃暂停期间的数据)、**自动Y**(开/关自动
纵轴,关闭后可在图上按住左键上下拖动自定义 Y 轴范围)、**实时**(取消
滞后跳回最新)、**滚轮**在图上缩放时间窗(2s~15min)。
实例:回包 payload `01 AE 75 6D`(前 2 字节固定、第 3~4 字节是 16 位大端
采样值 0x7444~0x7696)→ 勾选通道1,方式=二进制,偏移=2,格式=u16 大端,
倍率按传感器量纲填(如 0.01);最新值显示在通道行尾并进入曲线。
注意偏移是 payload 内的字节序号(0=第 1 字节),选错位宽/偏移会把多个字段
拼成一个数(如 u32@偏移0 会把整包读成 28,210,029);数值显示为千分位普通
小数,≥10 亿才用科学计数。
- **板型档案**:不同板子的"协议参数 + 命令预设表"存为命名档案,保存在 exe
同目录的 `board_profiles.json`。发送卡选中板型即一键套用其协议参数
(包头/板ID/帧构成/LEN口径/CRC)并切换命令预设;**新建板型**按当前协议参数
建档、命令预设从空白开始;**存为板型**把当前配置存成档案(同名覆盖);
**删除板型**带确认(至少保留一个);"存入预设"把当前输入的命令加入当前
板型(可自定义显示名称);**配置**打开表格编辑器(名称/命令两列,+加行
−删行,保存写回档案);预设下拉以"名称 · 命令"同显。
- **发送**:
- **自动包装**:输入协议载荷(如 `M731S0T1000`)自动组帧加包头/板ID/LEN/CRC;
多条用 `/` 分隔依次发送(如 `M1/M731S0T1000`);
- **HEX 直发**:输入完整帧 hex 原样发出;
- 周期发送(100ms~60s)、输入框实时组帧预览、预设命令一键填入、
命令历史 ↑↓ 回溯、回车即发送。
## 运行
- 直接运行 `release/SerialMate.exe`(免安装,Windows 10/11 x64);
- 或源码运行:`py -3 -m pip install PySide6 PySide6-Fluent-Widgets pyserial`,
然后 `cd src && python main.py`。
## 界面速览
```
┌ SerialMate ─────────────────────────────────────────────────┐
│ 串口卡 协议卡 │ 接收卡(payload 字符串/HEX) │
│ COMx 115200 8N1 D5/B5/01 │ ← 12:00:00 B=0xF0 成功 LEN=2 ok │
│ 曲线卡(4通道) │ ┌───────── 滚动曲线 ─────────┐ │
│ ☑ DMD温度 文本数字 第1个 ×0.1 │ └────────────────────────────┘ │
│ │ 发送卡: [M1/M731S0T1000] 发送 │
└────────────────────────────────┴──────────────────────────────────┘
```
## 典型用法:调光机 RS485
1. 串口卡选 USB-RS485 适配器,115200 8N1,连接;
2. 发送框输入 `M1` 回车——自动包装为 `D5 01 02 4D 31 81` 发出,
接收区回 `ok`(协议卡默认即 DLPilot 协议);
3. 查温度:发 `M201`,回包 `okTL:246` 译码显示"成功",曲线卡勾选通道
设为"文本数字·第1个·×0.1",周期发送勾上,即可看温度曲线;
4. 换任何自定义协议:改协议卡的**帧构成**(字段顺序/有无板ID/字面量同步头)、
包头/板ID/LEN 口径/CRC 即可,收发解析同步生效;调好后点"存为板型",
以后一键切换。
## 目录
```
SerialMate/
├── src/ 源代码(protocol 协议 / link 串口线程 / main GUI / selftest 自检)
├── assets/ serial-mate.ico 图标(assets/make_icon.py 生成)
├── release/ SerialMate.exe + README(发布文件夹,zip 只上传 Release)
└── README.md 本文
```
运行时用户数据与 exe 同目录平铺:`serial_mate.json`(串口/协议/通道记忆)、
`board_profiles.json`(板型档案,首次运行自动生成默认"DLPilot光机")。
## 开发 / 发布
```powershell
cd src
python selftest.py # 协议回归(无依赖, 改 protocol.py 后必跑)
python selftest_loop.py # 链路回环自检(loop:// 虚拟口, 需 pyserial)
python main.py # 源码运行
python build_exe.py # → ../release/SerialMate.exe
python package_release.py <版本> # → release/SerialMate-v<版本>-win64.zip
python publish_gitea.py ../release/SerialMate-v<版本>-win64.zip --replace --insecure
```
工程约束与进度见 [AGENTS.md](AGENTS.md);协议语义唯一权威是上游固件仓
`protocols.md`(本仓 DLPilot 目录有引用说明)。
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
SerialMate 图标栅格化(家族风格, 与 DLPilot/EDID-Viewer/FlashTool 同版式):
黑底 512, 绿色圆角矩形, 第一行 Serial(框内黑字), 第二行 Mate(白字)
================================================================
设计参数: 黑底 512, 绿色圆角矩形 x38 y64 w436 h186 rx34 (#00C878, 与
gd32e230f8_firmware_merge_tool 的 FlashTool 同色系), Arial Bold。
Serial 6 字母按框宽自适应字号(上限 146px, 留边距),
Mate 146px 白字(基线对齐同 DLPilot 的 Pilot)。
用法: python make_icon.py # 生成 serial-mate.png / serial-mate.ico
"""
import os
from PIL import Image, ImageDraw, ImageFont
BASE = os.path.dirname(os.path.abspath(__file__))
FONT = r'C:\Windows\Fonts\arialbd.ttf' # Arial Bold, Windows 标配
def fit_font(draw, text, max_w, start):
"""从 start 字号往下找能装入 max_w 宽的字号(步进 2)"""
size = start
while size > 8:
f = ImageFont.truetype(FONT, size)
box = draw.textbbox((0, 0), text, font=f)
if box[2] - box[0] <= max_w:
return f
size -= 2
return ImageFont.truetype(FONT, 8)
def render(size=512):
s = size / 512.0
img = Image.new('RGB', (size, size), (0, 0, 0))
d = ImageDraw.Draw(img)
d.rounded_rectangle([38 * s, 64 * s, (38 + 436) * s, (64 + 186) * s],
radius=34 * s, fill=(0, 200, 120))
if not os.path.exists(FONT):
raise SystemExit(f'未找到字体 {FONT}, 无法栅格化')
# Serial: 黑字居中于橙色框, 按框宽自适应(留 2×18px 边距)
f1 = fit_font(d, 'Serial', 400 * s, round(146 * s))
d.text((size / 2, 64 * s + 186 * s / 2), 'Serial', font=f1,
fill=(0, 0, 0), anchor='mm')
# Mate: 白字, 与家族一致 146px(无下伸字母, 中心 = 基线421 - cap/2 ~= 368)
f2 = ImageFont.truetype(FONT, max(8, round(146 * s)))
d.text((size / 2, 368 * s), 'Mate', font=f2, fill=(255, 255, 255),
anchor='mm')
return img
if __name__ == '__main__':
img = render(512)
png = os.path.join(BASE, 'serial-mate.png')
ico = os.path.join(BASE, 'serial-mate.ico')
img.save(png)
img.save(ico, sizes=[(16, 16), (24, 24), (32, 32), (48, 48),
(64, 64), (128, 128), (256, 256)])
print(f'写出 {png}')
print(f'写出 {ico}')
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+133
View File
@@ -0,0 +1,133 @@
# SerialMate — RS485/串口调试助手
> LT8619C 调试工具集配套工具:串口收发 + 协议包解析 + 曲线绘制。
> 帧格式(包头/板ID/LEN/CRC)全可定制,默认与 [DLPilot](../DLPilot) 光机
> RS485 协议一致,可用来直接调试板载 GD32E230 网关或任意同构协议设备。
## 功能
- **串口收发**:端口自动枚举、波特率 1200~921600、数据位/校验/停止位可选,
参数热切换;半双工 RS485 友好(每周期最多写一帧,留应答间隙)。
- **协议解析**(可关,关=原始透传):
增量字节流定界 + CRC 校验,失败逐字节重同步,噪声自动丢弃;
接收帧 ID 位按 DLPilot 语义译码显示(0xF0=成功、0xFD=参数越界…)。
- **帧构成定制**(协议卡,发送/接收共用同一布局,仅包头值分方向):
帧构成用**令牌序列**定义,默认 `HDR ID LEN PAYLOAD CRC`(即 DLPilot 五段式):
| 令牌 | 含义 |
|---|---|
| `HDR` | 包头(值取 TX包头 / RX包头 字段) |
| `ID` | 板ID(RX 侧多为响应 TYPE 码,不校验仅透出显示) |
| `LEN` | 长度字节,口径可选:**payload 长度(默认)** / 到帧尾(payload+CRC) / 整帧 |
| `PAYLOAD` | 协议载荷(发送框内容,别名 DATA) |
| `CRC` | 校验(算法/覆盖范围见下表,必须在末尾) |
| 十六进制字面量 | 固定字节,如 `AA 55` 双字节同步头 |
示例:`AA 55 LEN PAYLOAD CRC`(AA55 同步头、无板ID)·
`HDR LEN PAYLOAD ID CRC`(板ID 在载荷后)· `HDR LEN ID PAYLOAD CRC`(板ID 在长度后)。
约束:LEN/PAYLOAD/CRC 恰好各一次,LEN 前至少一个 HDR 或字面量作同步头。
| 项 | 说明 | 默认 |
|---|---|---|
| TX 包头 | HDR 令牌在命令帧的值 | `D5` |
| RX 包头 | HDR 令牌在响应帧的值 | `B5` |
| 板ID | ID 令牌的值(TX 用;RX 侧仅透出) | `01` |
| LEN 口径 | payload 长度 / 到帧尾(payload+CRC) / 整帧 | payload 长度 |
| CRC 算法 | SUM8 累加和 / CRC8 / CRC16 MODBUS / CRC16 XMODEM / CRC16 CCITT-FALSE | SUM8 |
| CRC 覆盖 | 含包头(整帧) / 不含包头(跳过第1字节) / 仅 payload | 不含包头 |
- **接收显示**:payload 一键切换 **字符串 / HEX**;可选附原始帧 hex;
时间戳为**字节到达时刻**(串口后台线程打点随帧携带,UI 繁忙时日志时间
依然可信);日志保存为 txt。接收日志自动治理:行数上限 5000(超量淘汰
最旧)、批量刷新、事件积压时高频帧日志自动折叠为计数摘要(曲线照常
采集)——长时间高频运行不累积卡顿。
- **曲线**:4 通道,每帧有效数据按通道提取一个数值,支持三种提取方式
(二进制定长 u8~f32 大小端 / 文本第 N 个数字 / 自定义正则)+ 倍率,
y 轴自动或手动量程,暂停/清空;绘图区可**弹成独立窗口**("弹出"按钮,
关掉窗口即嵌回),通道配置与暂停等控件留在主界面照常控制。弹出窗口内:
纵轴每条参考线标注数值刻度;**时间窗**可选宽度(跟随/10s/30s/1min/5min/
15min),配合横向滑条回看历史总体趋势或缩小窗口细看局部走势,拉到最右
恢复实时跟随(冻结查看请配合"暂停");窗口宽度不向数据收敛——数据不足时
显示完整的所选时间轴、曲线贴右端随采集逐渐填满(示波器式),右下角标注
"已采集 Xs / 窗口 Ns";**鼠标悬停**在曲线上,绘图区顶部的小读数框持续显示
光标处时间点 T 与各通道在该时刻的 Y 轴数值(±10px 容差取最近采样);
弹出窗口工具栏:**暂停渲染**(只冻结画面方便查看,采集继续入库,点"继续
渲染"后把积累的数据一次性画出来;画面中央会显示"已冻结渲染"角标;注意
与主界面"暂停"的区别:那个会丢弃暂停期间的数据)、**自动Y**(开/关自动
纵轴,关闭后可在图上按住左键上下拖动自定义 Y 轴范围)、**实时**(取消
滞后跳回最新)、**滚轮**在图上缩放时间窗(2s~15min)。
实例:回包 payload `01 AE 75 6D`(前 2 字节固定、第 3~4 字节是 16 位大端
采样值 0x7444~0x7696)→ 勾选通道1,方式=二进制,偏移=2,格式=u16 大端,
倍率按传感器量纲填(如 0.01);最新值显示在通道行尾并进入曲线。
注意偏移是 payload 内的字节序号(0=第 1 字节),选错位宽/偏移会把多个字段
拼成一个数(如 u32@偏移0 会把整包读成 28,210,029);数值显示为千分位普通
小数,≥10 亿才用科学计数。
- **板型档案**:不同板子的"协议参数 + 命令预设表"存为命名档案,保存在 exe
同目录的 `board_profiles.json`。发送卡选中板型即一键套用其协议参数
(包头/板ID/帧构成/LEN口径/CRC)并切换命令预设;**新建板型**按当前协议参数
建档、命令预设从空白开始;**存为板型**把当前配置存成档案(同名覆盖);
**删除板型**带确认(至少保留一个);"存入预设"把当前输入的命令加入当前
板型(可自定义显示名称);**配置**打开表格编辑器(名称/命令两列,+加行
−删行,保存写回档案);预设下拉以"名称 · 命令"同显。
- **发送**:
- **自动包装**:输入协议载荷(如 `M731S0T1000`)自动组帧加包头/板ID/LEN/CRC;
多条用 `/` 分隔依次发送(如 `M1/M731S0T1000`);
- **HEX 直发**:输入完整帧 hex 原样发出;
- 周期发送(100ms~60s)、输入框实时组帧预览、预设命令一键填入、
命令历史 ↑↓ 回溯、回车即发送。
## 运行
- 直接运行 `release/SerialMate.exe`(免安装,Windows 10/11 x64);
- 或源码运行:`py -3 -m pip install PySide6 PySide6-Fluent-Widgets pyserial`,
然后 `cd src && python main.py`。
## 界面速览
```
┌ SerialMate ─────────────────────────────────────────────────┐
│ 串口卡 协议卡 │ 接收卡(payload 字符串/HEX) │
│ COMx 115200 8N1 D5/B5/01 │ ← 12:00:00 B=0xF0 成功 LEN=2 ok │
│ 曲线卡(4通道) │ ┌───────── 滚动曲线 ─────────┐ │
│ ☑ DMD温度 文本数字 第1个 ×0.1 │ └────────────────────────────┘ │
│ │ 发送卡: [M1/M731S0T1000] 发送 │
└────────────────────────────────┴──────────────────────────────────┘
```
## 典型用法:调光机 RS485
1. 串口卡选 USB-RS485 适配器,115200 8N1,连接;
2. 发送框输入 `M1` 回车——自动包装为 `D5 01 02 4D 31 81` 发出,
接收区回 `ok`(协议卡默认即 DLPilot 协议);
3. 查温度:发 `M201`,回包 `okTL:246` 译码显示"成功",曲线卡勾选通道
设为"文本数字·第1个·×0.1",周期发送勾上,即可看温度曲线;
4. 换任何自定义协议:改协议卡的**帧构成**(字段顺序/有无板ID/字面量同步头)、
包头/板ID/LEN 口径/CRC 即可,收发解析同步生效;调好后点"存为板型",
以后一键切换。
## 目录
```
SerialMate/
├── src/ 源代码(protocol 协议 / link 串口线程 / main GUI / selftest 自检)
├── assets/ serial-mate.ico 图标(assets/make_icon.py 生成)
├── release/ SerialMate.exe + README(发布文件夹,zip 只上传 Release)
└── README.md 本文
```
运行时用户数据与 exe 同目录平铺:`serial_mate.json`(串口/协议/通道记忆)、
`board_profiles.json`(板型档案,首次运行自动生成默认"DLPilot光机")。
## 开发 / 发布
```powershell
cd src
python selftest.py # 协议回归(无依赖, 改 protocol.py 后必跑)
python selftest_loop.py # 链路回环自检(loop:// 虚拟口, 需 pyserial)
python main.py # 源码运行
python build_exe.py # → ../release/SerialMate.exe
python package_release.py <版本> # → release/SerialMate-v<版本>-win64.zip
python publish_gitea.py ../release/SerialMate-v<版本>-win64.zip --replace --insecure
```
工程约束与进度见 [AGENTS.md](AGENTS.md);协议语义唯一权威是上游固件仓
`protocols.md`(本仓 DLPilot 目录有引用说明)。
+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)
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
把 src/main.py 打包成单文件 exe(工具名: SerialMate)
================================================
与 EDID-Viewer/src/build_exe.py 同一套路: onefile + --noconsole, Python 解释器、
PySide6 和 PySide6-Fluent-Widgets 全部打进一个 exe, 目标机器无需安装依赖。
用法:
python build_exe.py 自动安装 PyInstaller(如缺)并打包
python build_exe.py --debug 额外打一个带控制台的变体
产物: release/SerialMate.exe
spec 由本脚本生成到 src/build/(不入库), 调整打包逻辑直接改这里的模板。
"""
import os
import subprocess
import sys
# 本脚本位于 src/, 打包产物输出到工具根的 release/
SRC = os.path.dirname(os.path.abspath(__file__))
BASE = os.path.dirname(SRC)
RELEASE = os.path.join(BASE, 'release')
WORK = os.path.join(SRC, 'build')
ICO = os.path.join(BASE, 'assets', 'serial-mate.ico')
SPEC_TEMPLATE = """\
# -*- coding: utf-8 -*-
# 由 build_exe.py 生成, 不要手改; 调整打包逻辑请改 src/build_exe.py。
from PyInstaller.utils.hooks import collect_all
qfluent_datas, qfluent_binaries, qfluent_hidden = collect_all('qfluentwidgets')
a = Analysis(
[r'{entry}'], pathex=[r'{src}'], binaries=qfluent_binaries,
datas=qfluent_datas + {icon_datas}, hiddenimports=qfluent_hidden,
noarchive=False)
# Qt 6.11 使用 Windows 非版本化 ICU 导出; PATH 上 Poppler/Conda 的版本化
# ICU(icuuc.dll/icudt78.dll)会被 PyInstaller 误收, 令 QtCore.pyd 启动报
# WinError 127 —— 打包时剔除, 运行时用系统自带 ICU。
a.binaries = [b for b in a.binaries if b[0].lower() not in ('icuuc.dll', 'icudt78.dll')]
pyz = PYZ(a.pure)
exe = EXE(pyz, a.scripts, a.binaries, a.datas, [], name='{name}',
debug=False, strip=False, upx=False, console={console}{icon_clause})
"""
def ensure_pyinstaller():
try:
import PyInstaller # noqa: F401
return
except ImportError:
pass
print('PyInstaller 未安装, 正在安装...')
subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'pyinstaller'])
def remove_stale(exe_path):
"""删除旧 exe; 若被占用(上次验证的进程还在), 先结束同名进程再重试"""
if not os.path.exists(exe_path):
return
try:
os.remove(exe_path)
return
except PermissionError:
pass
import time
print(f'{exe_path} 被占用, 结束残留进程后重试...')
subprocess.run(['taskkill', '/F', '/IM', os.path.basename(exe_path)],
capture_output=True)
time.sleep(1.0)
os.remove(exe_path)
def build(name, console):
remove_stale(os.path.join(RELEASE, name + '.exe'))
if os.path.exists(ICO):
icon_datas = f"[(r'{ICO}', 'assets')]"
icon_clause = f", icon=r'{ICO}'"
else:
print('警告: 未找到 assets/serial-mate.ico, 本次打包不带图标')
icon_datas, icon_clause = '[]', ''
spec = SPEC_TEMPLATE.format(entry=os.path.join(SRC, 'main.py'), src=SRC,
icon_datas=icon_datas, name=name,
console=console, icon_clause=icon_clause)
os.makedirs(WORK, exist_ok=True)
spec_path = os.path.join(WORK, name + '.spec')
with open(spec_path, 'w', encoding='utf-8') as f:
f.write(spec)
cmd = [sys.executable, '-m', 'PyInstaller', '--noconfirm', '--clean',
'--distpath', RELEASE, '--workpath', WORK, spec_path]
print('\n>>', ' '.join(cmd))
subprocess.check_call(cmd)
out = os.path.join(RELEASE, name + '.exe')
print(f'完成: {out} ({os.path.getsize(out) / 1024 / 1024:.1f} MB)')
def main():
build_console = '--debug' in sys.argv
ensure_pyinstaller()
# 常规版: 双击即用, 无黑框
build('SerialMate', 'False')
if build_console:
# 调试版: 带控制台, 异常栈可见
build('SerialMate-console', 'True')
if __name__ == '__main__':
main()
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
运行目录定位与设置持久化(BASE_DIR 语义与 DLPilot config.py 一致)
==================================================================
frozen(PyInstaller onefile)时 BASE_DIR=exe 所在目录(sys.executable),
源码运行时=config.py 所在目录(即 src/)。设置写/读自 BASE_DIR 平铺的
serial_mate.json, 不建子目录。
读容错: 文件缺失/损坏/缺键一律回落默认值, 并保证返回的 dict 键全齐,
GUI 无需判空。
"""
import json
import os
import shutil
import sys
if getattr(sys, 'frozen', False):
BASE_DIR = os.path.dirname(os.path.abspath(sys.executable))
else:
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_FILE = os.path.join(BASE_DIR, 'serial_mate.json')
LEGACY_CONFIG = os.path.join(BASE_DIR, 'serial_assistant.json') # 更名前的配置
PROFILES_FILE = os.path.join(BASE_DIR, 'board_profiles.json')
def _migrate_legacy_config():
"""SerialAssistant -> SerialMate 更名迁移: 旧配置搬为新名(一次性, 已存在则跳过)"""
if os.path.exists(CONFIG_FILE) or not os.path.exists(LEGACY_CONFIG):
return
try:
shutil.copy2(LEGACY_CONFIG, CONFIG_FILE)
except OSError:
pass
DEFAULTS = {
'serial': {'port': '', 'baud': 115200, 'bytesize': 8, 'parity': 'N', 'stopbits': 1},
'proto': {'tx_hdr': 'D5', 'rx_hdr': 'B5', 'board': '01',
'crc_algo': 'SUM8', 'crc_scope': 'no_header', 'parse': True,
'layout': 'HDR ID LEN PAYLOAD CRC', 'len_sem': 'payload'},
'tx': {'mode': 'wrap', 'periodic': False, 'interval_ms': 1000, 'profile': ''},
'rx': {'display': 'text', 'show_raw': True},
'channels': [
{'enabled': False, 'name': '通道1', 'mode': 'num', 'offset': 0,
'fmt': 'u16be', 'index': 1, 'pattern': '(?<=:)\\d+', 'scale': 1.0},
{'enabled': False, 'name': '通道2', 'mode': 'bin', 'offset': 0,
'fmt': 's16be', 'index': 1, 'pattern': '(?<=:)\\d+', 'scale': 1.0},
{'enabled': False, 'name': '通道3', 'mode': 'bin', 'offset': 0,
'fmt': 'u16be', 'index': 1, 'pattern': '(?<=:)\\d+', 'scale': 1.0},
{'enabled': False, 'name': '通道4', 'mode': 'bin', 'offset': 0,
'fmt': 'u16be', 'index': 1, 'pattern': '(?<=:)\\d+', 'scale': 1.0},
],
}
# 板型档案的帧协议字段(不含 parse —— 解析开关是查看偏好, 不随板型走)
_PROFILE_PROTO_KEYS = ('tx_hdr', 'rx_hdr', 'board', 'layout', 'len_sem',
'crc_algo', 'crc_scope')
# 首个板型的命令预设(DLPilot 光机命令集, 全表见上游 protocols.md)
DEFAULT_COMMANDS = (
('握手', 'M1'), ('查版本', 'M999'),
('开光机', 'M730S0'), ('关光机', 'M730S1'),
('设光强', 'M731S100'), ('查电流', 'M732'),
('查DMD温度', 'M201'), ('查LAMP温度', 'M202'),
('TEC自动控温开', 'M7S0'), ('TEC自动控温关', 'M7S1'),
('凝露点33.0°C', 'M8S330'), ('软件重启', 'M888'),
)
def default_profiles():
"""首个板型档案: DLPilot 光机(协议字段取默认值)"""
return [{'name': 'DLPilot光机',
'proto': {k: DEFAULTS['proto'][k] for k in _PROFILE_PROTO_KEYS},
'commands': [list(c) for c in DEFAULT_COMMANDS]}]
def _merge(base, over):
"""递归覆盖: over 缺键用 base 补齐, over 多余键保留(向前兼容旧配置)"""
out = dict(base)
for k, v in over.items():
if k in base and isinstance(base[k], dict) and isinstance(v, dict):
out[k] = _merge(base[k], v)
else:
out[k] = v
return out
def load():
_migrate_legacy_config()
"""读设置; 任何异常回落默认(键全齐), 不抛出"""
try:
with open(CONFIG_FILE, encoding='utf-8') as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError('配置根不是对象')
except Exception:
return json.loads(json.dumps(DEFAULTS)) # 深拷贝默认值
return _merge(DEFAULTS, data)
def save(cfg):
"""写设置; 失败静默(只读目录等场景不阻塞退出)"""
try:
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(cfg, f, ensure_ascii=False, indent=1)
except Exception:
pass
def load_profiles():
"""读板型档案(board_profiles.json); 缺失/损坏回落默认并落盘。
档案结构: {'profiles': [{'name', 'proto'{7字段}, 'commands'[[名,命令]..]}..]}"""
try:
with open(PROFILES_FILE, encoding='utf-8') as f:
data = json.load(f)
profiles = data.get('profiles') if isinstance(data, dict) else data
if isinstance(profiles, list) and profiles:
out = []
for p in profiles:
if not isinstance(p, dict) or not p.get('name'):
continue
out.append({'name': str(p['name']),
'proto': {k: p.get('proto', {}).get(k,
DEFAULTS['proto'][k])
for k in _PROFILE_PROTO_KEYS},
'commands': [[str(n), str(c)]
for n, c in p.get('commands', [])
if isinstance(n, str) and isinstance(c, str)]})
if out:
return out
except Exception:
pass
profiles = default_profiles()
save_profiles(profiles)
return profiles
def save_profiles(profiles):
"""写板型档案; 失败静默并返回 False"""
try:
with open(PROFILES_FILE, 'w', encoding='utf-8') as f:
json.dump({'profiles': profiles}, f, ensure_ascii=False, indent=1)
return True
except Exception:
return False
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
串口链路线程: 端口/波特率热切换 + 发送队列 + 接收分发
======================================================
与 DLPilot 的 DLPC485 同一套路(D I2C-Inject Injector 同源), 差异点:
- 帧格式参数(ProtoConfig)可运行中热替换, 解析器配置随之切换并清残余半帧;
- 支持协议解析(Frame 回调)与透传(原始 bytes 回调)两种接收模式, 运行中切换;
- 发送一律传完整帧 bytes(组帧由 GUI 侧按 ProtoConfig 完成, 本模块不关心格式)。
回调全在后台线程触发, GUI 侧必须自行投递回主线程(见 main.py 的轮询队列)。
pyserial 为本模块独占依赖(缺失只影响串口功能)。
"""
import collections
import queue
import re
import threading
import time
try:
import serial
import serial.tools.list_ports
HAS_SERIAL = True
except ImportError:
HAS_SERIAL = False
DEFAULT_BAUD = 115200
DEFAULTS = dict(bytesize=8, parity='N', stopbits=1)
class SerialLink:
"""串口工作线程。回调(后台线程触发, 第二参数 ts=事件发生的 epoch 秒):
on_status(text, ts) 连接状态变化(已连接/断开/失败/设备拔出)
on_frame(fr, ts) 解析模式: 完整 Frame(protocol.Frame); 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):
self.on_status = on_status
self.on_frame = None
self.on_raw = None
self.on_tx = None
self.q = queue.Queue()
self._cfg = cfg
self._parse = True
self._target = None # 意图 (port, baud, bytesize, parity, stopbits)
self._open_key = None # 当前已打开串口的参数元组
self.ser = None
self.parser = None # 惰性建: 协议模块无 pyserial 依赖, 此处才用到
self._lock = threading.Lock()
threading.Thread(target=self._run, daemon=True).start()
# --- 配置与模式(线程安全) ---
def set_config(self, cfg):
"""热替换帧格式; 解析器半帧缓冲随之清空, 避免新旧格式混接"""
with self._lock:
self._cfg = cfg
if self.parser is not None:
self.parser.cfg = cfg
self.parser.clear()
def set_parse(self, enabled):
"""切换 解析协议帧 / 原始透传; 切换时清解析缓冲"""
with self._lock:
self._parse = bool(enabled)
if self.parser is not None:
self.parser.clear()
# --- 串口管理 ---
@staticmethod
def list_ports_info():
"""枚举串口, 返回 [(设备名, 显示标签), ...]。
标签 = "COMx 设备描述"(描述里的 "(COMx)" 尾巴去掉), CH344 这类多路
适配器每一路描述不同(serial-A/B/C/D), 依此区分; 多口描述完全相同时
追加物理位置/序列号消歧。
"""
if not HAS_SERIAL:
return []
items = []
for p in serial.tools.list_ports.comports():
desc = re.sub(r'\s*\((COM\d+)\)\s*$', '', (p.description or '').strip(),
flags=re.I)
if desc.upper() == p.device.upper():
desc = ''
label = f'{p.device} {desc}'.rstrip()
tail = p.location or p.serial_number or ''
items.append((p.device, label, tail))
counts = collections.Counter(label for _d, label, _t in items)
out = []
for dev, label, tail in items:
if counts[label] > 1 and tail:
label = f'{label} [{tail}]'
out.append((dev, label))
return out
def open(self, port, baud=DEFAULT_BAUD, **kw):
if not HAS_SERIAL:
self.on_status('未安装 pyserial: py -m pip install pyserial', time.time())
return
params = {**DEFAULTS, **kw}
with self._lock:
self._target = (port, int(baud), int(params['bytesize']),
str(params['parity']).upper(), int(params['stopbits']))
def close(self):
with self._lock:
self._target = None
def is_connected(self):
with self._lock:
return self.ser is not None
# --- 发送(队列化, 后台线程写串口) ---
def send_frame(self, frame, label=''):
"""发送一个完整帧 bytes; label 用于回显(自动包装时为 payload 文本)。
未连接时直接丢弃并提示(防积压到重连后一起发出, DLPilot 同策略)"""
with self._lock:
ser = self.ser
if ser is None:
self.on_status('未连接串口, 命令被丢弃', time.time())
return False
self.q.put((label, bytes(frame)))
return True
# --- 工作线程 ---
def _run(self):
bytesizes = {8: serial.EIGHTBITS, 7: serial.SEVENBITS,
6: serial.SIXBITS, 5: serial.FIVEBITS}
parities = {'N': serial.PARITY_NONE, 'E': serial.PARITY_EVEN,
'O': serial.PARITY_ODD}
stopbits = {1: serial.STOPBITS_ONE, 2: serial.STOPBITS_TWO}
while True:
with self._lock:
target = self._target
cur = self._open_key
if target != cur:
if self.ser is not None:
try:
self.ser.close()
except Exception:
pass
self.ser = None
self._open_key = None
self.on_status('已断开', time.time())
if target:
port, baud, dbsize, parity, sbits = target
try:
maker = (serial.serial_for_url if '://' in port
else serial.Serial) # '://' 支持 loop:// 等虚拟端口(自检用)
self.ser = maker(
port, baud, bytesize=bytesizes.get(dbsize, serial.EIGHTBITS),
parity=parities.get(parity, serial.PARITY_NONE),
stopbits=stopbits.get(sbits, serial.STOPBITS_ONE),
timeout=0.15,
write_timeout=1.0, # 写超时: 转换器停滞时 write 会
# 永久阻塞并卡死线程, 必须设上限
inter_byte_timeout=0.02) # 字节间超时(Win32
# ReadIntervalTimeout): timeout=0.15 是"总超时"
# (150ms 内必返回, 不会无限攒批), 周期上报帧下 read
# 会 ~150ms 攒 1~2 帧才返回一批; 设 20ms 后按帧粒度
# 返回, 帧到达时间戳精确到单帧。20ms 仍大于 1200 波特
# 单字节时间(~8.3ms)×2, 不误切帧
with self._lock:
self._open_key = target
self.parser = None # 换口重建解析器(下次接收时惰性建)
self.on_status(f'已连接 {port} @ {baud} {dbsize}{parity}{sbits}',
time.time())
except Exception as e:
self.ser = None
with self._lock:
self._target = None
et = str(e)
if 'PermissionError' in repr(e) or '拒绝访问' in et \
or 'Access is denied' in et:
et += '(串口被占用: 检查串口助手/另一个本工具实例)'
self.on_status(f'连接失败: {et}', time.time())
if self.ser is not None:
try:
item = None
try:
item = self.q.get_nowait()
except queue.Empty:
pass
if item is not None:
label, frame = item
self.ser.write(frame)
if self.on_tx is not None:
self.on_tx(label, frame, time.time())
data = self.ser.read(4096)
if data:
self._dispatch_rx(data)
except OSError:
try:
self.ser.close()
except Exception:
pass
self.ser = None
self._open_key = None # 必须同步清除, 否则同参数重连被当作已打开而跳过
with self._lock:
self._target = None
self.on_status('串口丢失(设备拔出?), 请重新连接', time.time())
time.sleep(0.02)
def _dispatch_rx(self, data):
ts = time.time() # 字节到达时刻: 后台线程打, 随帧携带
with self._lock:
parse = self._parse
if parse and self.parser is None: # 惰性建, 带当前 ProtoConfig
from protocol import FrameParser
self.parser = FrameParser(self._cfg)
if parse:
for fr in self.parser.feed(data):
if self.on_frame is not None:
self.on_frame(fr, ts)
elif self.on_raw is not None:
self.on_raw(data, ts)
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
组装 SerialMate 发布包: exe + README → 一个 zip(解压即用)
================================================
把主 README 复制进 release/ 后, 与单文件 exe 一起压成 zip —— 两者内容一致。
zip 解压后根目录为 SerialMate/:
SerialMate.exe 主程序(先运行 build_exe.py 打包)
README.md 使用说明
用法:
python package_release.py 版本号自动取最近 git tag(SerialMate-v*)
python package_release.py 0.1.0 手动指定版本号
产物: release/SerialMate-v<版本>-win64.zip(zip 不入库, 上传到 Gitea Release)
"""
import os
import shutil
import subprocess
import sys
import zipfile
SRC = os.path.dirname(os.path.abspath(__file__))
BASE = os.path.dirname(SRC)
RELEASE = os.path.join(BASE, 'release')
def detect_version():
"""从最近的 SerialMate-v* tag 推断版本号, 无 tag 则报错提示手动指定"""
try:
out = subprocess.check_output(
['git', 'describe', '--tags', '--abbrev=0', '--match', 'SerialMate-v*'],
cwd=BASE, stderr=subprocess.DEVNULL)
return out.decode().strip().removeprefix('SerialMate-v')
except Exception:
sys.exit('未能从 git tag 推断版本号, 请手动指定: python package_release.py <版本号>')
def main():
version = (sys.argv[1] if len(sys.argv) > 1 else detect_version()).lstrip('v')
exe = os.path.join(RELEASE, 'SerialMate.exe')
readme = os.path.join(BASE, 'README.md')
if not os.path.exists(exe):
sys.exit('缺少 release/SerialMate.exe, 先运行: python build_exe.py')
if not os.path.exists(readme):
sys.exit(f'缺少 {readme}')
shutil.copy2(readme, os.path.join(RELEASE, 'README.md'))
zip_name = f'SerialMate-v{version}-win64.zip'
zip_path = os.path.join(RELEASE, zip_name)
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z:
z.write(exe, f'SerialMate/{os.path.basename(exe)}')
z.write(os.path.join(RELEASE, 'README.md'), 'SerialMate/README.md')
print(f'发布文件夹: {RELEASE} (exe + README)')
print(f'发布包: {zip_path} ({os.path.getsize(zip_path) / 1024 / 1024:.1f} MB)')
print('下一步上传: python publish_gitea.py', zip_name)
if __name__ == '__main__':
main()
+405
View File
@@ -0,0 +1,405 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
轻量滚动曲线控件(QPainter 绘制, 不引入 matplotlib/pyqtgraph)
================================================================
N 通道时间序列: add(ch, value) 追加点(内部取相对时间), deque 限长滚动丢弃;
y 轴自动缩放(全平数据回落 ±1)或 set_yrange 固定量程; 颜色/文字取自当前
palette, 明暗主题自动跟随。每通道 >1000 点时抽稀绘制, 3000 点 × 4 通道
重绘开销可控。
"""
import bisect
import time
from collections import deque
from PySide6.QtCore import QPointF, QRect, Qt
from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath, QPen, QPalette
from PySide6.QtWidgets import QSizePolicy, QWidget
def fmt_val(v):
"""数值可读显示: 普通小数带千分位(最多 4 位小数, 去尾零), 达 10 亿量级
才退回科学计数 —— 调试现场 2.821e+07 这类写法不直观"""
if v != v or v in (float('inf'), float('-inf')):
return str(v)
if abs(v) >= 1e9:
return f'{v:.4g}'
s = f'{v:,.4f}' if abs(v) < 1 else f'{v:,.3f}'
return s.rstrip('0').rstrip('.') or '0'
class PlotWidget(QWidget):
COLORS = ('#0f6cbd', '#d83b01', '#107c10', '#8764b8')
MAXLEN = 3000 # 每通道缓冲点数(超限滚动丢弃)
M_LEFT, M_TOP, M_RIGHT, M_BOTTOM = 52, 10, 12, 22 # 绘图区内边距
def __init__(self, channels=4, parent=None):
super().__init__(parent)
n = channels
self._times = [deque(maxlen=self.MAXLEN) for _ in range(n)]
self._vals = [deque(maxlen=self.MAXLEN) for _ in range(n)]
self._names = [''] * n
self._latest = [None] * n
self._paused = False
self._yrange = None # None=自动 或 (min, max)
self._span = None # 可见时间窗宽(秒); None=跟随全部
self._offset = 0.0 # 右端距最新数据的秒数(0=实时)
self._frozen = False # 暂停渲染: 继续收数但不重绘
self._drag_y = None # Y 轴拖动基准(仅手动量程)
self.on_yrange_changed = None # 拖动改 Y 量程回调(主界面同步控件)
self.on_span_changed = None # 滚轮改时间窗回调(主界面同步下拉)
self._hover_pos = None # 悬停光标位置(None=不在绘图区)
self._hover_lines = [] # 悬停读值(顶部小框内容)
self._t0 = time.perf_counter()
self.setMinimumHeight(150)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.setMouseTracking(True) # 无按键也接收 mouseMove(悬停读值)
# --- 数据接口(主线程调用) ---
def set_channel_name(self, ch, name):
self._names[ch] = name
self._touch()
def set_yrange(self, yr):
self._yrange = yr
self._touch()
def set_view(self, span):
"""设置可见时间窗宽(秒); None=跟随全部数据"""
self._span = None if span is None else max(float(span), 1.0)
self._touch()
def set_offset(self, offset):
"""可见窗右端距最新数据的秒数(0=实时跟随最新)"""
self._offset = max(float(offset), 0.0)
self._touch()
def view_offset(self):
return self._offset
def view_span(self):
return self._span
def view_range(self):
"""(全部数据 tmin, tmax, 可回看的最大滞后秒数) —— 供时间滑条换算"""
t_all = [t for ts in self._times if ts for t in (ts[0], ts[-1])]
tmin = min(t_all) if t_all else 0.0
tmax = max(t_all) if t_all else tmin + 5.0
span = self._span or max(tmax - tmin, 5.0)
return tmin, tmax, max(tmax - tmin - span, 0.0)
def visible_domain(self, tmin, tmax):
"""按时间窗/滞后换算可见横轴域(纯计算, 供单测)。
不做数据边界收敛: 选 5min 窗而数据只有 23s 时, 显示完整的 5min 轴,
数据贴右端随采集逐渐填满(示波器式); 滞后超出全部历史则为空窗"""
if self._span is None:
return tmin, tmax
right = tmax - self._offset
left = right - self._span
if right <= left: # 滞后超出全部历史 -> 空窗保宽
right = left + self._span
return left, right
def auto_yrange(self):
"""自动 Y 量程: 可见数据的 min/max 加 8% 边距(全平数据回落 ±1)"""
visible = [v for ch in range(len(self._vals)) for v in self._vals[ch]]
if not visible:
return -1.0, 1.0
ylo, yhi = min(visible), max(visible)
if ylo == yhi:
return ylo - 1.0, yhi + 1.0
pad = (yhi - ylo) * 0.08
return ylo - pad, yhi + pad
def set_paused(self, paused):
self._paused = bool(paused)
def is_paused(self):
return self._paused
def set_frozen(self, frozen):
"""暂停渲染(弹窗用): 继续接收入库但不重绘; 解冻后一次性渲染全部积累。
冻结是绝对状态: 覆写 update() 吞掉一切重绘请求, 任何代码路径都无法
在冻结期打破(悬停/滑条等), 只有 set_frozen(False) 恢复"""
self._frozen = bool(frozen)
if not self._frozen:
super().update()
def is_frozen(self):
return self._frozen
def update(self):
if self._frozen:
return
super().update()
def _touch(self):
self.update()
def add(self, ch, value):
if self._paused or value is None:
return
self._times[ch].append(time.perf_counter() - self._t0)
self._vals[ch].append(float(value))
self._latest[ch] = float(value)
self._touch()
def clear(self):
for t in self._times:
t.clear()
for v in self._vals:
v.clear()
self._latest = [None] * len(self._latest)
self._touch()
# --- 悬停读值 ---
def _nearest(self, ch, t, tol):
"""通道 ch 在时刻 t 附近(±tol 秒)最近的采样点 -> (名称, 值) 或 None"""
ts, vs = self._times[ch], self._vals[ch]
if not ts:
return None
lst = list(ts) # deque 无随机 bisect, 拷贝代价可接受
i = bisect.bisect_left(lst, t)
best = None
for j in (i - 1, i):
if 0 <= j < len(lst) and abs(lst[j] - t) <= tol:
if best is None or abs(lst[j] - t) < abs(lst[best] - t):
best = j
if best is None:
return None
return self._names[ch] or f'CH{ch + 1}', vs[best]
def _plot_geometry(self):
"""绘图区几何与可见横轴域: (left, top, pw, ph, tmin, tmax)"""
left, top = self.M_LEFT, self.M_TOP
pw = self.width() - left - self.M_RIGHT
ph = self.height() - top - self.M_BOTTOM
t_all = [t for ts in self._times if ts for t in (ts[0], ts[-1])]
if t_all:
tmin, tmax = min(t_all), max(t_all)
else:
tmin, tmax = 0.0, 5.0
tmin, tmax = self.visible_domain(tmin, tmax)
return left, top, pw, ph, tmin, tmax
def wheelEvent(self, ev):
"""滚轮缩放时间窗: 上滚放大(窗宽变短), 下滚缩小; 滞后按比例缩放"""
delta = ev.angleDelta().y()
if delta:
factor = 0.8 if delta > 0 else 1.25
old = self._span
if old is None: # 跟随模式 -> 以当前数据跨度为基准
_tmin, tmax, _mp = self.view_range()
old = max(tmax - _tmin, 5.0)
new = max(min(old * factor, 900.0), 2.0)
if self._offset and old:
self._offset = self._offset * new / old
self.set_view(new)
if self.on_span_changed is not None:
self.on_span_changed()
super().wheelEvent(ev)
def mousePressEvent(self, ev):
if ev.button() == Qt.LeftButton and self._yrange is not None:
self._drag_y = ev.position().toPoint().y()
super().mousePressEvent(ev)
def mouseReleaseEvent(self, ev):
if ev.button() == Qt.LeftButton:
self._drag_y = None
super().mouseReleaseEvent(ev)
def mouseMoveEvent(self, ev):
pos = ev.position().toPoint()
if self._drag_y is not None and self._yrange is not None:
_left, _top, _pw, ph, *_rest = self._plot_geometry()
if ph > 40:
ylo, yhi = self._yrange
shift = (yhi - ylo) * (pos.y() - self._drag_y) / ph
yr = (ylo - shift, yhi - shift) # 下拖数据下移, 上拖上移
self.set_yrange(yr)
if self.on_yrange_changed is not None:
self.on_yrange_changed(yr)
self._drag_y = pos.y()
self._hover_pos = pos
self.update()
super().mouseMoveEvent(ev)
def leaveEvent(self, ev):
self._hover_pos = None
self.update()
super().leaveEvent(ev)
def _hover_lines_at(self, pos):
"""光标位置 -> 读值框内容; 不在绘图区/无命中返回空列表。
由 paintEvent 每帧调用(而非仅在鼠标移动时), 鼠标停住不动,
实时数据到来时读值依然持续刷新(用户实测反馈)"""
left, top, pw, ph, tmin, tmax = self._plot_geometry()
span = max(tmax - tmin, 0.001)
if pw < 40 or ph < 40 or not (left <= pos.x() <= left + pw) \
or not (top <= pos.y() <= top + ph):
return []
t = tmin + (pos.x() - left) / pw * span
tol = span / max(pw, 1) * 10 # 光标 ±10px 内的采样算命中
lines = [f'T = {t:.2f}s']
for ch in range(len(self._vals)):
got = self._nearest(ch, t, tol)
if got:
lines.append(f'{got[0]} = {fmt_val(got[1])}')
return lines if len(lines) > 1 else []
# --- 绘制 ---
def paintEvent(self, _ev):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
rect = self.rect()
pal = self.palette()
p.fillRect(rect, pal.color(QPalette.Base))
text = pal.color(QPalette.WindowText)
dim = QColor(text)
dim.setAlpha(110)
grid = QColor(text)
grid.setAlpha(38)
left, top = self.M_LEFT, self.M_TOP
right, bottom = self.M_RIGHT, self.M_BOTTOM
pw, ph = rect.width() - left - right, rect.height() - top - bottom
if pw < 40 or ph < 40:
return
p.setFont(QFont(self.font().family(), 8))
p.setPen(dim)
# y 量程: 固定 或 自动(auto_yrange: 8% 边距, 全平数据回落 ±1)
if self._yrange is not None:
ylo, yhi = self._yrange
if ylo >= yhi:
yhi = ylo + 1.0
else:
ylo, yhi = self.auto_yrange()
def my(y): # 值 -> 像素 y
return top + (yhi - y) / (yhi - ylo) * ph
# 横向参考线(4 等分)与每条线上的纵轴数值刻度
p.setPen(QPen(grid, 1, Qt.DashLine))
for i in range(1, 4):
y = top + ph * i / 4
p.drawLine(left, int(y), left + pw, int(y))
for i in range(1, 6):
x = left + pw * i / 6
p.drawLine(int(x), top, int(x), top + ph)
p.setPen(dim)
for i in range(5):
v = yhi - (yhi - ylo) * i / 4
y = top + ph * i / 4
p.drawText(QRect(2, int(y) - 9, left - 8, 18),
Qt.AlignRight | Qt.AlignVCenter, fmt_val(v))
# x 范围: 时间窗(span)内、按滞后(offset)取可见域; 无窗宽则跟随全部数据
t_all = [t for ch in range(len(self._times)) if self._times[ch]
for t in (self._times[ch][0], self._times[ch][-1])]
if t_all:
tmin, tmax = min(t_all), max(t_all)
else:
tmin, tmax = 0.0, 5.0
data_span = tmax - tmin
tmin, tmax = self.visible_domain(tmin, tmax)
span = max(tmax - tmin, 0.001)
def mx(t): # 时间 -> 像素 x
return left + (t - tmin) / span * pw
empty = True
for ch in range(len(self._vals)):
ts, vs = self._times[ch], self._vals[ch]
n = len(vs)
if n == 0:
continue
empty = False
color = QColor(self.COLORS[ch % len(self.COLORS)])
p.setPen(QPen(color, 1.6))
if n == 1:
p.drawEllipse(QPointF(mx(ts[0]), my(vs[0])), 2.2, 2.2)
continue
step = max(1, n // 1000)
path = QPainterPath(QPointF(mx(ts[0]), my(vs[0])))
for i in range(step, n, step):
path.lineTo(mx(ts[i]), my(vs[i]))
path.lineTo(mx(ts[-1]), my(vs[-1]))
p.drawPath(path)
p.setPen(dim)
tag = '窗口' if self._span is not None else '最近'
if self._span is not None and data_span < self._span - 0.5:
label = f'已采集 {data_span:.0f}s / 窗口 {self._span:.0f}s'
else:
label = f'{tag} {span:.0f}s' if span >= 10 else f'{tag} {span:.1f}s'
p.drawText(rect.adjusted(0, rect.height() - 18, -4, 0),
Qt.AlignRight | Qt.AlignVCenter, label)
if self._hover_pos is not None:
self._hover_lines = self._hover_lines_at(self._hover_pos)
if self._hover_lines:
self._hover_box(p)
if empty:
p.drawText(rect, Qt.AlignCenter, '等待数据…(勾选通道并使能接收)')
elif self._paused:
p.drawText(rect.adjusted(0, top, 0, 0), Qt.AlignHCenter, '已暂停')
elif self._frozen:
p.drawText(rect.adjusted(0, top, 0, 0), Qt.AlignHCenter,
'已冻结渲染(采集继续)')
self._legend(p, rect, text)
def _hover_box(self, p):
"""顶部居中的悬停读值小框(反色底), 内容在 mouseMove 里持续刷新"""
p.setFont(QFont(self.font().family(), 9))
fm = p.fontMetrics()
w = max(fm.horizontalAdvance(s) for s in self._hover_lines) + 16
h = fm.height() * len(self._hover_lines) + 10
x = self.M_LEFT + (self.width() - self.M_LEFT - self.M_RIGHT - w) // 2
y = self.M_TOP + 6
bg = QColor(self.palette().color(QPalette.WindowText))
bg.setAlpha(210)
p.setPen(Qt.NoPen)
p.setBrush(bg)
p.drawRoundedRect(x, y, w, h, 4, 4)
p.setPen(self.palette().color(QPalette.Base))
for i, s in enumerate(self._hover_lines):
p.drawText(x + 8, y + 5 + i * fm.height(), w - 16, fm.height(),
Qt.AlignLeft | Qt.AlignVCenter, s)
p.setBrush(Qt.NoBrush)
def _legend(self, p, rect, text):
"""右上角图例: 色块 + 通道名 + 最新值"""
lines = []
for ch in range(len(self._vals)):
if self._latest[ch] is None:
continue
name = self._names[ch] or f'CH{ch + 1}'
lines.append((ch, f'{name} = {fmt_val(self._latest[ch])}'))
if not lines:
return
p.setFont(QFont(self.font().family(), 8))
fm = p.fontMetrics()
w = max(fm.horizontalAdvance(s) for _c, s in lines) + 26
h = fm.height() * len(lines) + 8
x1, y1 = rect.width() - 16 - w, 8
bg = QColor(text)
bg.setAlpha(18)
p.setPen(Qt.NoPen)
p.setBrush(bg)
p.drawRoundedRect(x1, y1, w, h, 4, 4)
p.setBrush(Qt.NoBrush)
for i, (ch, s) in enumerate(lines):
y = y1 + 4 + i * fm.height()
p.setPen(Qt.NoPen)
p.setBrush(QColor(self.COLORS[ch % len(self.COLORS)]))
p.drawRect(x1 + 6, y + fm.ascent() - 8, 8, 8)
p.setPen(self.palette().color(QPalette.WindowText))
p.drawText(x1 + 20, y, w - 22, fm.height(),
Qt.AlignLeft | Qt.AlignVCenter, s)
+450
View File
@@ -0,0 +1,450 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
SerialAssistant 串口协议模块(纯标准库, 帧构成可定制)
======================================================
帧由"构成令牌序列"定义(默认与 DLPilot 光机 RS485 协议完全一致):
HDR ID LEN PAYLOAD CRC -> 包头 + 板ID + LEN + payload + CRC
LEN = payload 字节数(口径可选, 见 LEN_SEMS), 默认即"包长对应 payload 长度"
令牌: HDR=包头 ID=板ID LEN=长度 PAYLOAD=载荷 CRC=校验(必须最后),
其余 1~2 位十六进制(如 AA 55)为固定字面量字节(双字节同步头)。
约束: LEN/PAYLOAD/CRC 恰好各一次; LEN 前至少一个 HDR 或字面量(同步定界用)。
同一布局收发共用, 仅包头值分方向(TX=tx_hdr / RX=rx_hdr); ID 在 RX 侧
按上游语义多为响应 TYPE 码, 解析时不校验、原样透出(Frame.code, 可为 None)。
CRC 算法(2 字节算法在线上的字节序: MODBUS 低位在前, 其余高位在前):
SUM8 累加和取低 8 位(1 字节)
CRC8 poly 0x07 init 0x00(1 字节, 标准校验值 check("123456789")=0xF4)
CRC16_MODBUS poly 0x8005 反射 init 0xFFFF(2 字节, check=0x4B37)
CRC16_XMODEM poly 0x1021 init 0x0000(2 字节, check=0x31C3)
CRC16_CCITT poly 0x1021 init 0xFFFF(2 字节, check=0x29B1)
CRC 覆盖范围(CRC_SCOPES, 按帧内位置定义, 任意布局通用):
with_header 包头(第1字节)起至 CRC 前
no_header 第 2 字节起至 CRC 前(DLPilot 默认)
payload_only 仅 payload
仅标准库; pyserial 只允许出现在 link.py。
"""
import collections
import re
import struct
LEN_MAX = 255 # LEN 为单字节, payload 上限 255
TYPE_NAMES = { # DLPilot/上游 protocols.md 的响应 TYPE 码语义, 用于 RX 译码显示
0xF0: '成功',
0xF1: 'CRC 校验错',
0xF2: '包头错',
0xF3: '板类型错',
0xF4: '格式/缺参数',
0xFD: '参数越界/读回复核不一致',
0xFE: '未知命令',
0xFF: '通用错误(含就绪门禁拒绝)',
0xFA: '开光机通信失败',
0xFB: '关光机通信失败',
0xFC: '读回复核通信失败',
}
# --- CRC 实现(查表, 供高波特率下增量解析用) ---------------------------------
def _table_crc8():
tbl = []
for b in range(256):
c = b
for _ in range(8):
c = ((c << 1) ^ 0x07) & 0xFF if c & 0x80 else (c << 1) & 0xFF
tbl.append(c)
return tuple(tbl)
def _table_crc16_msb():
"""MSB-first 表(poly 0x1021, 用于 XMODEM/CCITT)"""
tbl = []
for b in range(256):
c = (b << 8) & 0xFFFF
for _ in range(8):
c = ((c << 1) ^ 0x1021) & 0xFFFF if c & 0x8000 else (c << 1) & 0xFFFF
tbl.append(c)
return tuple(tbl)
def _table_crc16_reflected():
"""反射表(poly 0x8005 → 0xA001, 用于 MODBUS)"""
tbl = []
for b in range(256):
c = b
for _ in range(8):
c = (c >> 1) ^ 0xA001 if c & 1 else c >> 1
tbl.append(c)
return tuple(tbl)
_TAB_CRC8 = _table_crc8()
_TAB_CRC16_MSB = _table_crc16_msb()
_TAB_CRC16_REF = _table_crc16_reflected()
def _sum8(data):
return sum(data) & 0xFF
def _crc8(data, _t=_TAB_CRC8):
c = 0
for b in data:
c = _t[c ^ b]
return c
def _crc16_msb(data, init, _t=_TAB_CRC16_MSB):
c = init
for b in data:
c = ((c << 8) & 0xFFFF) ^ _t[((c >> 8) ^ b) & 0xFF]
return c
def _crc16_xmodem(data):
return _crc16_msb(data, 0x0000)
def _crc16_ccitt(data):
return _crc16_msb(data, 0xFFFF)
def _crc16_modbus(data, _t=_TAB_CRC16_REF):
c = 0xFFFF
for b in data:
c = (c >> 8) ^ _t[(c ^ b) & 0xFF]
return c
# name -> (CRC 字节数, 计算函数, 线上字节序: 'big'=高字节在前 / 'little'=低字节在前)
CRC_ALGOS = {
'SUM8': (1, _sum8, 'big'),
'CRC8': (1, _crc8, 'big'),
'CRC16_MODBUS': (2, _crc16_modbus, 'little'),
'CRC16_XMODEM': (2, _crc16_xmodem, 'big'),
'CRC16_CCITT': (2, _crc16_ccitt, 'big'),
}
CRC_ALGO_LABELS = { # 下拉框显示文本
'SUM8': 'SUM8 累加和',
'CRC8': 'CRC8 (poly 07)',
'CRC16_MODBUS': 'CRC16 MODBUS',
'CRC16_XMODEM': 'CRC16 XMODEM',
'CRC16_CCITT': 'CRC16 CCITT-FALSE',
}
CRC_SCOPES = ('with_header', 'no_header', 'payload_only')
CRC_SCOPE_LABELS = {
'with_header': '含包头(整帧不含CRC)',
'no_header': '不含包头(跳过第1字节, DLPilot)',
'payload_only': '仅 payload',
}
LEN_SEMS = ('payload', 'body', 'total')
LEN_SEM_LABELS = { # LEN 字节的计数口径
'payload': 'payload 长度(默认)',
'body': '到帧尾(payload+CRC)',
'total': '整帧(含包头)',
}
DEFAULT_LAYOUT = 'HDR ID LEN PAYLOAD CRC'
_FRAME_TOKEN_RE = re.compile(r'^(?:0x)?([0-9A-Fa-f]{1,2})$')
_KEYWORDS = {'HDR': 'hdr', 'ID': 'id', 'BOARD': 'id', 'LEN': 'len',
'PAYLOAD': 'payload', 'DATA': 'payload', 'CRC': 'crc'}
class FrameLayout:
"""帧构成: 令牌序列(parse 文本而来), 供组帧与解析共用。
tokens 元素: 'hdr'/'id'/'len'/'payload'/'crc' 或 ('lit', 字节值)。
约束(parse 内校验): LEN/PAYLOAD/CRC 恰好各一次; CRC 必须最后;
LEN 之前至少一个 HDR 或字面量(否则无同步定界); ID 最多一次。
payload 之前的令牌都是 1 字节(CRC 在最后), 故 payload 字节偏移=payload_pos。
"""
def __init__(self, tokens):
self.tokens = tuple(tokens)
counts = collections.Counter(
t if isinstance(t, str) else 'lit' for t in self.tokens)
for key, cn in (('len', 'LEN'), ('payload', 'PAYLOAD'), ('crc', 'CRC')):
if counts[key] != 1:
raise ValueError(f'{cn} 必须恰好出现一次(当前 {counts[key]} 次)')
if self.tokens[-1] != 'crc':
raise ValueError('CRC 必须是最后一个令牌')
if counts['id'] > 1:
raise ValueError('ID 最多出现一次')
self.len_pos = self.tokens.index('len')
self.payload_pos = self.tokens.index('payload')
id_positions = [i for i, t in enumerate(self.tokens) if t == 'id']
self.id_pos = id_positions[0] if id_positions else None
if not any(isinstance(t, tuple) or t == 'hdr'
for t in self.tokens[:self.len_pos]):
raise ValueError('LEN 之前需要至少一个 HDR 或十六进制字面量作同步头')
@classmethod
def parse(cls, text):
"""'AA 55 ID LEN PAYLOAD CRC' 之类 -> FrameLayout; 非法抛 ValueError"""
parts = [p for p in re.split(r'[\s,,;;]+', (text or '').strip()) if p]
if not parts:
raise ValueError('帧构成为空')
tokens = []
for p in parts:
kw = _KEYWORDS.get(p.upper())
if kw:
tokens.append(kw)
continue
m = _FRAME_TOKEN_RE.match(p)
if not m:
raise ValueError(f'未知令牌 "{p}"(可用: HDR ID LEN PAYLOAD CRC '
f'或 0~FF 十六进制字面量)')
tokens.append(('lit', int(m.group(1), 16)))
return cls(tokens)
# --- 尺寸(依赖 CRC 字节数, 由 ProtoConfig 传参) ---
def rest_size(self, crc_size):
"""payload 之后的字节数(ID/HDR/字面量 + CRC)"""
return crc_size + len(self.tokens) - self.payload_pos - 2
def total_size(self, payload_len, crc_size):
# payload 之前的令牌都是 1 字节, payload 字节偏移即 payload_pos
return self.payload_pos + payload_len + self.rest_size(crc_size)
def len_extra(self, len_sem, crc_size):
"""LEN 字节值 = payload_len + 该口径下的附加字节数"""
if len_sem == 'body':
return self.rest_size(crc_size)
if len_sem == 'total':
return self.payload_pos + self.rest_size(crc_size)
return 0
Frame = collections.namedtuple('Frame', 'code payload raw')
"""code=ID 位置字节(RX 侧多为响应 TYPE 码; 布局无 ID 时为 None),
payload=原始字节, raw=整帧含 CRC"""
class ProtoConfig:
"""协议配置: 帧构成(FrameLayout) + 包头(TX/RX)/板ID/LEN 口径/CRC 算法与覆盖"""
def __init__(self, tx_hdr=0xD5, rx_hdr=0xB5, board=0x01,
crc_algo='SUM8', crc_scope='no_header',
layout=None, len_sem='payload'):
for name, v in (('tx_hdr', tx_hdr), ('rx_hdr', rx_hdr), ('board', board)):
if not 0 <= int(v) <= 0xFF:
raise ValueError(f'{name} 超出 0~255: {v}')
if crc_algo not in CRC_ALGOS:
raise ValueError(f'未知 CRC 算法: {crc_algo}')
if crc_scope not in CRC_SCOPES:
raise ValueError(f'未知 CRC 覆盖范围: {crc_scope}')
if len_sem not in LEN_SEMS:
raise ValueError(f'未知 LEN 口径: {len_sem}')
self.tx_hdr, self.rx_hdr, self.board = int(tx_hdr), int(rx_hdr), int(board)
self.crc_algo, self.crc_scope = crc_algo, crc_scope
self.len_sem = len_sem
self.layout = layout if layout is not None else FrameLayout.parse(DEFAULT_LAYOUT)
@property
def crc_size(self):
return CRC_ALGOS[self.crc_algo][0]
# --- CRC ---
def compute_crc(self, nocrc_frame, payload_len):
"""对不含 CRC 的整帧按覆盖范围切片算 CRC 值(切片按位置定义, 任意布局通用)"""
_size, fn, _order = CRC_ALGOS[self.crc_algo]
if self.crc_scope == 'no_header':
region = nocrc_frame[1:]
elif self.crc_scope == 'payload_only':
po = self.layout.payload_pos
region = nocrc_frame[po:po + payload_len]
else:
region = nocrc_frame
return fn(region)
def encode_crc(self, value):
"""CRC 值 -> 线上字节(2 字节算法按算法约定字节序)"""
size, _fn, order = CRC_ALGOS[self.crc_algo]
if size == 1:
return bytes((value & 0xFF,))
hi, lo = (value >> 8) & 0xFF, value & 0xFF
return bytes((hi, lo)) if order == 'big' else bytes((lo, hi))
# --- 组帧 ---
def build_frame(self, payload):
"""payload bytes -> 完整帧 bytes; 空载荷允许(LEN=0), 超限抛 ValueError"""
payload = bytes(payload)
if len(payload) > LEN_MAX:
raise ValueError(f'payload 超长(>{LEN_MAX} 字节)')
cs = self.crc_size
len_val = len(payload) + self.layout.len_extra(self.len_sem, cs)
if len_val > LEN_MAX:
raise ValueError(f'LEN 字节溢出(={len_val}>255), 检查帧构成与 LEN 口径')
parts = []
for tok in self.layout.tokens:
if tok == 'crc':
continue # CRC 最后, 跳过待拼
if tok == 'hdr':
parts.append(bytes((self.tx_hdr,)))
elif tok == 'id':
parts.append(bytes((self.board,)))
elif tok == 'len':
parts.append(bytes((len_val,)))
elif tok == 'payload':
parts.append(payload)
else:
parts.append(bytes((tok[1],)))
nocrc = b''.join(parts)
return nocrc + self.encode_crc(self.compute_crc(nocrc, len(payload)))
# --- 校验与拆帧 ---
def _plen_of(self, raw):
"""从整帧 LEN 字节解出 payload 长度; 非法(负值)返回 None"""
plen = raw[self.layout.len_pos] - self.layout.len_extra(self.len_sem, self.crc_size)
return plen if plen >= 0 else None
def verify_frame(self, raw):
"""整帧(含 CRC)校验: 长度与 LEN 口径自洽 + CRC 匹配"""
cs = self.crc_size
if len(raw) < self.layout.len_pos + 1 + cs:
return False
plen = self._plen_of(raw)
if plen is None or len(raw) != self.layout.total_size(plen, cs):
return False
return self.encode_crc(self.compute_crc(raw[:-cs], plen)) == raw[-cs:]
def parse_frame(self, raw):
"""整帧 -> Frame(不校验 CRC, 调用方先用 verify_frame); 长度不符抛 ValueError"""
cs = self.crc_size
plen = self._plen_of(raw)
if plen is None or len(raw) != self.layout.total_size(plen, cs):
raise ValueError('帧长与 LEN 口径不符')
po = self.layout.payload_pos
code = raw[self.layout.id_pos] if self.layout.id_pos is not None else None
return Frame(code, raw[po:po + plen], bytes(raw))
class FrameParser:
"""增量字节流 -> 完整帧: 前缀令牌逐位对齐(帧头/字面量, ID 为通配) + LEN 定长
+ CRC 校验; 任一处失配丢 1 字节重新同步。LEN 值经口径换算后即 payload 长度,
负值视为伪帧头逐出 —— 伪帧头最多滞留一帧即被逐出重同步, 对调试工具可接受。
"""
def __init__(self, cfg):
self.cfg = cfg
lay = cfg.layout
cs = cfg.crc_size
# LEN 之前的前缀令牌: (帧内位置, 期望字节|None=通配); hdr 按 RX 包头匹配
self._prefix = []
for pos, tok in enumerate(lay.tokens[:lay.len_pos]):
exp = None
if tok == 'hdr':
exp = cfg.rx_hdr
elif isinstance(tok, tuple):
exp = tok[1]
self._prefix.append((pos, exp))
self._len_pos = lay.len_pos
self._extra = lay.len_extra(cfg.len_sem, cs)
self._buf = bytearray()
def clear(self):
self._buf.clear()
def _aligned(self):
"""把缓冲对齐到前缀匹配; 字节不足且已匹配部分则保持等待(返回 True),
失配丢 1 字节重试, 缓冲耗尽返回 False"""
buf = self._buf
while True:
for pos, exp in self._prefix:
if pos >= len(buf):
return True # 已匹配部分全对, 等更多字节
if exp is not None and buf[pos] != exp:
break # 失配 -> 丢 1 字节
else:
return True
del buf[:1]
if not buf:
return False
def feed(self, data):
"""喂入新收到的字节, 返回本次解析出的完整 Frame 列表"""
self._buf += bytes(data)
cfg, frames = self.cfg, []
while self._aligned() and self._buf:
if len(self._buf) < self._len_pos + 1:
break # LEN 字节未到齐
plen = self._buf[self._len_pos] - self._extra
if plen < 0: # LEN 为负 -> 伪帧头, 逐出重同步
del self._buf[:1]
continue
total = cfg.layout.total_size(plen, cfg.crc_size)
if len(self._buf) < total:
break # 整帧未收齐
raw = bytes(self._buf[:total])
if cfg.verify_frame(raw):
frames.append(cfg.parse_frame(raw))
del self._buf[:total]
else:
del self._buf[:1] # CRC 失配 -> 丢帧头重同步
return frames
# --- payload 数值提取(曲线通道用) --------------------------------------------
BIN_FORMATS = { # fmt -> (struct 格式, 字节数)
'u8': ('B', 1), 's8': ('b', 1),
'u16be': ('>H', 2), 'u16le': ('<H', 2),
's16be': ('>h', 2), 's16le': ('<h', 2),
'u32be': ('>I', 4), 'u32le': ('<I', 4),
's32be': ('>i', 4), 's32le': ('<i', 4),
'f32be': ('>f', 4), 'f32le': ('<f', 4),
}
NUM_RE = re.compile(r'-?\d+(?:\.\d+)?')
def extract_value(payload, mode, *, offset=0, fmt='u16be', index=1,
pattern=r'(?<=:)\d+', scale=1.0):
"""从 payload 提取一个数值(已乘 scale); 提取失败返回 None。
bin 二进制定长提取: offset 起, 按 fmt(BIN_FORMATS 键)解
num ASCII 文本第 index(1 基)个数字, 如 'okTL:246' -> 246
re 正则第 1 捕获组(无组则整体), 如 '(?<=:)(\\d+)'
"""
payload = bytes(payload)
try:
if mode == 'bin':
if fmt not in BIN_FORMATS:
return None
sfmt, size = BIN_FORMATS[fmt]
if offset < 0 or len(payload) < offset + size:
return None
value = struct.unpack(sfmt, payload[offset:offset + size])[0]
elif mode == 'num':
nums = NUM_RE.findall(payload.decode('ascii', 'replace'))
if not 1 <= index <= len(nums):
return None
value = float(nums[index - 1])
elif mode == 're':
m = re.search(pattern, payload.decode('ascii', 'replace'))
if not m:
return None
value = float(m.group(1) if m.groups() else m.group(0))
else:
return None
except (ValueError, re.error):
return None
return value * scale
def hexs(data):
"""bytes -> 'AA BB CC' 大写 hex, 日志统一格式"""
return bytes(data).hex(' ').upper()
def clean_text(s):
"""控制字符 -> '·': 二进制按 ASCII 解出的 NUL 等会污染日志行,
且 Windows 剪贴板遇 NUL 直接截断复制内容(DLPilot 同源教训)"""
return ''.join(c if ord(c) >= 0x20 and ord(c) != 0x7F else '·' for c in s)
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
创建/更新 Gitea Release 并上传发布 zip(纯标准库, 无需安装依赖)
================================================================
用法:
python publish_gitea.py SerialMate-v0.1.0-win64.zip
python publish_gitea.py <zip> --notes-file release/notes-v0.1.0.md
python publish_gitea.py <zip> --tag SerialMate-v0.1.0 --replace
行为:
- tag 与版本默认取 zip 文件名(SerialMate-vX.Y.Z-win64.zip → SerialMate-vX.Y.Z)
- release 不存在则创建(tag 不存在时 Gitea 在默认分支头自动建 tag);
已存在则复用, 仅上传/更新资产
- release 上已有同名资产时: 默认跳过, --replace 删旧再传
- API token: --token 参数或环境变量 GITEA_TOKEN(需该仓库写权限)
- 本实例 TLS 间歇抽风: 已内置连接级重试, 实际发布请加 --insecure
"""
import argparse
import json
import os
import re
import ssl
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
DEFAULT_NOTES = """## SerialMate {version}
RS485/串口调试助手(LT8619C 调试工具集配套): 串口收发 + 协议包解析,
帧格式(包头/板ID/LEN/CRC 算法与覆盖范围)全可定制, 默认与 DLPilot 光机
RS485 协议一致; payload 支持字符串/HEX 显示、4 通道曲线绘制, 发送框输入
payload 自动包装组帧(多条用 / 分隔), 也可 HEX 直发与周期发送。
### 安装
下载 `SerialMate-v{version}-win64.zip`, 解压后双击 `SerialMate.exe`
(Windows 10/11 x64, 免安装; 串口功能需系统可访问串口, 程序自带 pyserial)。
### 包内容
- `SerialMate.exe` — 主程序
- `README.md` — 完整使用说明
"""
def git_remote_info():
"""从 origin 远程地址解析 API base 与 owner/repo(ssh 端口不影响 https API)"""
url = subprocess.check_output(['git', 'remote', 'get-url', 'origin']).decode().strip()
m = re.match(r'(?:ssh://git@|git@)([\w.-]+)(?::\d+)?[:/]([\w.-]+)/([\w.-]+?)(?:\.git)?$', url)
if not m:
sys.exit(f'无法从 origin 解析 Gitea 地址: {url}')
host, owner, repo = m.groups()
return f'https://{host}', owner, repo
class Api:
def __init__(self, base, owner, repo, token, insecure):
self.api = f'{base}/api/v1/repos/{owner}/{repo}'
self.headers = {'Authorization': f'token {token}'}
self.ctx = ssl._create_unverified_context() if insecure else None
def request(self, method, path, data=None, headers=None, retries=4):
"""发请求; 实例 TLS 间歇性抽风(SSL UNEXPECTED_EOF), 连接级错误自动重试"""
last = None
for attempt in range(retries):
req = urllib.request.Request(self.api + path, data=data, method=method,
headers={**self.headers, **(headers or {})})
try:
with urllib.request.urlopen(req, context=self.ctx) as resp:
body = resp.read()
return resp.status, json.loads(body) if body else None
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b'{}')
except (urllib.error.URLError, ssl.SSLError, OSError) as e:
last = e
time.sleep(1.0 + attempt)
raise RuntimeError(f'Gitea 请求连续 {retries} 次失败: {last}')
def get_json(self, path):
status, data = self.request('GET', path)
return data if status == 200 else None
def upload_asset(self, release_id, filepath, replace):
name = os.path.basename(filepath)
status, rel = self.request('GET', f'/releases/{release_id}')
if status != 200:
sys.exit(f'读取 release {release_id} 失败: {status} {rel}')
# Gitea 版本差异: assets 可能是 {items:[...]} 或纯数组
assets = rel.get('assets') or []
if isinstance(assets, dict):
assets = assets.get('items', [])
for asset in assets:
if asset['name'] == name:
if not replace:
print(f'资产 {name} 已存在, 跳过(覆盖请加 --replace)')
return
self.request('DELETE', f"/releases/{release_id}/assets/{asset['id']}")
print(f'已删除旧资产 {name}')
boundary = uuid.uuid4().hex
payload = open(filepath, 'rb').read()
body = (f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'
f'Content-Type: application/octet-stream\r\n\r\n').encode() + payload + \
f'\r\n--{boundary}--\r\n'.encode()
status, data = self.request(
'POST', f'/releases/{release_id}/assets?name={name}', body,
{'Content-Type': f'multipart/form-data; boundary={boundary}'})
if status != 201:
sys.exit(f'上传资产失败: {status} {data}')
print(f'已上传资产: {name} ({len(payload) / 1024 / 1024:.1f} MB)')
def main():
ap = argparse.ArgumentParser()
ap.add_argument('zipfile_path', help='发布 zip(release/ 下)')
ap.add_argument('--tag', help='release 标签, 默认取 zip 文件名去掉 -win64.zip')
ap.add_argument('--notes-file', help='release 说明 markdown 文件, 默认用内置模板')
ap.add_argument('--token', default=os.environ.get('GITEA_TOKEN'), help='API token')
ap.add_argument('--replace', action='store_true', help='覆盖已存在的同名资产')
ap.add_argument('--insecure', action='store_true', help='跳过 TLS 证书校验(自签证书)')
args = ap.parse_args()
if not os.path.exists(args.zipfile_path):
sys.exit(f'找不到 {args.zipfile_path}')
if not args.token:
sys.exit('缺少 API token: 传 --token 或设环境变量 GITEA_TOKEN')
tag = args.tag or re.sub(r'-win64\.zip$', '', os.path.basename(args.zipfile_path))
version = tag.removeprefix('SerialMate-v')
notes = open(args.notes_file, encoding='utf-8').read() if args.notes_file \
else DEFAULT_NOTES.format(version=version)
base, owner, repo = git_remote_info()
api = Api(base, owner, repo, args.token, args.insecure)
print(f'目标: {base}/{owner}/{repo} tag={tag}')
rel = api.get_json(f'/releases/tags/{tag}')
if rel:
print(f'release 已存在(id={rel["id"]}), 复用')
else:
status, rel = api.request('POST', '/releases', json.dumps({
'tag_name': tag,
'name': f'SerialMate v{version}',
'body': notes,
}).encode(), {'Content-Type': 'application/json'})
if status != 201:
sys.exit(f'创建 release 失败: {status} {rel}')
print(f'release 已创建(id={rel["id"]})')
api.upload_asset(rel['id'], args.zipfile_path, args.replace)
print(f'完成: {rel.get("html_url", "") or f"{base}/{owner}/{repo}/releases/tag/{tag}"}')
if __name__ == '__main__':
main()
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""protocol.py 协议自检(无硬件): DLPilot 实测帧向量回归 + CRC 标准校验值 + 解析同步
用法: python selftest.py (全部通过退出码 0)
"""
import random
import sys
import protocol as d
fails = []
def check(name, got, want):
ok = got == want
print(('PASS' if ok else 'FAIL'), name, '->', got, '' if ok else f'(期望 {want})')
if not ok:
fails.append(name)
def check_close(name, got, want):
ok = isinstance(got, float) and abs(got - want) < 1e-6
print(('PASS' if ok else 'FAIL'), name, '->', got, '' if ok else f'(期望 {want})')
if not ok:
fails.append(name)
cfg = d.ProtoConfig() # 默认即 DLPilot: D5/B5/01/SUM8/不含包头
# 1) 帧构造: DLPilot selftest_rs485.py 的实测向量(上游 protocols.md)
for cmd, want in (
('M730S0T2000', 'D5 01 0B 4D 37 33 30 53 30 54 32 30 30 30 8C'),
('M303', 'D5 01 04 4D 33 30 33 E8'),
('M304', 'D5 01 04 4D 33 30 34 E9'),
('M305', 'D5 01 04 4D 33 30 35 EA'),
):
check(f'build_frame({cmd})', cfg.build_frame(cmd.encode('ascii')).hex(' ').upper(), want)
# 2) CRC 算法实现对照公开标准校验值 check("123456789")
check('crc8 check', d._crc8(b'123456789'), 0xF4)
check('crc16_modbus check', d._crc16_modbus(b'123456789'), 0x4B37)
check('crc16_xmodem check', d._crc16_xmodem(b'123456789'), 0x31C3)
check('crc16_ccitt check', d._crc16_ccitt(b'123456789'), 0x29B1)
# 3) CRC 覆盖范围(手算向量: 包头 5A 板ID 11, payload 01 02)
for scope, want in (('payload_only', '5A 11 02 01 02 03'),
('no_header', '5A 11 02 01 02 16'),
('with_header', '5A 11 02 01 02 70')):
c = d.ProtoConfig(tx_hdr=0x5A, board=0x11, crc_scope=scope)
check(f'coverage {scope}', c.build_frame(b'\x01\x02').hex(' ').upper(), want)
# 4) CRC16 帧往返 + 坏帧拒绝(组帧/解析同头才能回路, 收发方向包头本就允许不同)
c16 = d.ProtoConfig(tx_hdr=0x55, rx_hdr=0x55, crc_algo='CRC16_MODBUS', crc_scope='with_header')
f = c16.build_frame(b'ABC')
check('crc16 frame len', len(f), 3 + 3 + 2)
check('crc16 verify', c16.verify_frame(f), True)
bad = bytearray(f)
bad[4] ^= 0xFF
check('crc16 bad rejected', c16.verify_frame(bytes(bad)), False)
frames = d.FrameParser(c16).feed(f)
check('crc16 parse payload', (frames[0].payload, frames[0].code) if frames else None, (b'ABC', 0x01))
# 5) LEN=0 空载荷帧(心跳类): D5 01 00 + sum(01)=01
check('empty payload frame', cfg.build_frame(b'').hex(' ').upper(), 'D5 01 00 01')
loop = d.ProtoConfig(tx_hdr=0xB5) # tx/rx 同头以便回路
check('empty payload parse', d.FrameParser(loop).feed(loop.build_frame(b''))[0].payload, b'')
# 6) 超长 payload 拒绝
try:
cfg.build_frame(b'\x00' * 256)
fails.append('oversize-not-rejected')
except ValueError:
print('PASS 超长 payload 拒绝')
# 7) 增量解析: ok 应答逐字节喂入(拆到最碎) + 前导噪声
good = bytes.fromhex('B5 F0 02 6F 6B CC')
p = d.FrameParser(cfg)
out = []
for i in range(len(good)):
out += p.feed(good[i:i + 1])
check('byte-wise feed frames', len(out), 1)
check('code', out[0].code, 0xF0)
check('payload', out[0].payload, b'ok')
check('raw', d.hexs(out[0].raw), 'B5 F0 02 6F 6B CC')
out = d.FrameParser(cfg).feed(b'\x01\x02' + good)
check('noise+frame count', len(out), 1)
# 8) CRC 坏帧被丢弃后继续同步到下一帧
bad = bytearray(good)
bad[-1] ^= 0xFF
frames = d.FrameParser(cfg).feed(bytes(bad) + good)
check('bad-crc then good', len(frames), 1)
check('recovered code', frames[0].code, 0xF0)
# 9) 伪帧头与帧头字节入载荷
# 9a 小 LEN 伪帧头(B5 AA 01 55,CRC 应为 00 而实为 01)失败后逐字节重同步到真帧
frames = d.FrameParser(cfg).feed(b'\xB5\xAA\x01\x55\x01' + good)
check('pseudo-header resync', len(frames), 1)
check('resync payload', frames[0].payload, b'ok')
# 9b payload 内含 0xB5(帧头字节)的合法帧: B5 F0 02 B5 F0 + sum(F0 02 B5 F0)=0x297→97
frames = d.FrameParser(cfg).feed(bytes.fromhex('B5 F0 02 B5 F0 97') + good)
check('rx-hdr-in-payload', len(frames), 2)
check('rx-hdr-in-payload payload', frames[0].payload, b'\xB5\xF0')
# 10) 自定义包头/板ID 往返(非 DLPilot 值)
cx = d.ProtoConfig(tx_hdr=0xC3, rx_hdr=0xC3, board=0x7A)
frames = d.FrameParser(cx).feed(cx.build_frame(b'PING'))
check('custom hdr frame count', len(frames), 1)
check('custom hdr code', frames[0].code, 0x7A)
check('custom hdr payload', frames[0].payload, b'PING')
# 11) 数值提取(曲线通道)
check_close('bin u16be', d.extract_value(bytes.fromhex('00 34'), 'bin', fmt='u16be'), 52.0)
check_close('bin s16le 负值', d.extract_value(bytes.fromhex('FE FF'), 'bin', fmt='s16le'), -2.0)
check_close('bin f32be', d.extract_value(bytes.fromhex('3F 80 00 00'), 'bin', fmt='f32be'), 1.0)
check_close('bin offset', d.extract_value(bytes.fromhex('AA 00 34'), 'bin', offset=1, fmt='u16be'), 52.0)
check_close('num TL:246', d.extract_value(b'okTL:246', 'num'), 246.0)
check_close('num scale 0.1', d.extract_value(b'okTL:246', 'num', scale=0.1), 24.6)
check_close('num 第3个', d.extract_value(b'okR12G34B56', 'num', index=3), 56.0)
check_close('re 捕获组', d.extract_value(b'okTL:246', 're'), 246.0)
check('num 缺数字', d.extract_value(b'ok', 'num'), None)
check('bin 越界', d.extract_value(b'\x01', 'bin', offset=2), None)
check('re 非法表达式', d.extract_value(b'x', 're', pattern='('), None)
check('未知模式', d.extract_value(b'x', 'bogus'), None)
# 12) 随机往返模糊: 三种算法 × 三种覆盖范围, 整喂/乱拆两种方式
rng = random.Random(20260927)
for algo in ('SUM8', 'CRC8', 'CRC16_MODBUS'):
for scope in d.CRC_SCOPES:
cf = d.ProtoConfig(tx_hdr=0xB5, crc_algo=algo, crc_scope=scope) # tx=rx 回路
for _ in range(20):
pl = bytes(rng.randrange(256) for _ in range(rng.randrange(41)))
frame = cf.build_frame(pl)
fp = d.FrameParser(cf)
if rng.random() < 0.5:
got = fp.feed(frame)
else:
cut = rng.randrange(len(frame) + 1)
got = fp.feed(frame[:cut]) + fp.feed(frame[cut:])
ok = len(got) == 1 and got[0].payload == pl and got[0].raw == frame
if not ok:
check(f'fuzz {algo}/{scope}', ok, True)
break
print(f'模糊往返 3 算法 × 3 覆盖 × 20 帧: {"PASS" if not fails else "见上"}')
# 13) 日志行安全: clean_text 把控制字符换成 '·'
check('clean_text NUL', d.clean_text('a\x00b\x7f'), 'a·b·')
# 14) 帧构成定制(发送/接收同一布局, 收发方向仅包头值不同; 回路用例 rx_hdr=D5
# 以便解析器接受 TX 包头, 字面量同步头布局则与包头值无关)
# 14a 双字节字面量同步头 + 无板ID: AA 55 LEN payload CRC
cl = d.FrameLayout.parse('AA 55 LEN PAYLOAD CRC')
c = d.ProtoConfig(layout=cl)
check('layout 字面量组帧', c.build_frame(b'\x01\x02').hex(' ').upper(), 'AA 55 02 01 02 5A')
frames = d.FrameParser(c).feed(c.build_frame(b'\x01\x02'))
check('layout 字面量解析', (len(frames), frames[0].code, frames[0].payload) if frames else None,
(1, None, b'\x01\x02'))
# 14b 自定义字段顺序: HDR LEN ID PAYLOAD CRC(板ID 在 LEN 后)
c = d.ProtoConfig(rx_hdr=0xD5, board=0x2A,
layout=d.FrameLayout.parse('HDR LEN ID PAYLOAD CRC'))
check('layout 换序组帧', c.build_frame(b'OK').hex(' ').upper(), 'D5 02 2A 4F 4B C6')
frames = d.FrameParser(c).feed(c.build_frame(b'OK'))
check('layout 换序解析', (frames[0].code, frames[0].payload) if frames else None, (0x2A, b'OK'))
# 14c 无板ID布局: HDR LEN PAYLOAD CRC, Frame.code 应为 None
c = d.ProtoConfig(rx_hdr=0xD5, layout=d.FrameLayout.parse('HDR LEN PAYLOAD CRC'))
check('layout 无ID组帧', c.build_frame(b'M1').hex(' ').upper(), 'D5 02 4D 31 80')
frames = d.FrameParser(c).feed(c.build_frame(b'M1'))
check('layout 无ID解析', (frames[0].code, frames[0].payload) if frames else None,
(None, b'M1'))
# 14d LEN 口径: body(payload+CRC) / total(整帧) —— M303 载荷 4 字节
c = d.ProtoConfig(rx_hdr=0xD5, len_sem='body')
check('LEN 口径 body', c.build_frame(b'M303').hex(' ').upper(), 'D5 01 05 4D 33 30 33 E9')
frames = d.FrameParser(c).feed(c.build_frame(b'M303'))
check('LEN 口径 body 解析', frames[0].payload if frames else None, b'M303')
c = d.ProtoConfig(rx_hdr=0xD5, len_sem='total')
check('LEN 口径 total', c.build_frame(b'M303').hex(' ').upper(), 'D5 01 08 4D 33 30 33 EC')
frames = d.FrameParser(c).feed(c.build_frame(b'M303'))
check('LEN 口径 total 解析', frames[0].payload if frames else None, b'M303')
# 14e 仅 payload 覆盖 + 板ID 在 payload 后: 'HDR LEN PAYLOAD ID CRC'
c = d.ProtoConfig(rx_hdr=0xD5, board=0x7B, crc_scope='payload_only',
layout=d.FrameLayout.parse('HDR LEN PAYLOAD ID CRC'))
check('layout ID在payload后', c.build_frame(b'\x01').hex(' ').upper(), 'D5 01 01 7B 01')
frames = d.FrameParser(c).feed(c.build_frame(b'\x01'))
check('layout ID在payload后解析', (frames[0].code, frames[0].payload) if frames else None,
(0x7B, b'\x01'))
# 14f 非法帧构成一律拒绝
for bad in ('HDR PAYLOAD CRC', # 缺 LEN
'HDR LEN CRC PAYLOAD', # CRC 不在末尾
'LEN PAYLOAD CRC', # 无同步头
'HDR ID ID LEN PAYLOAD CRC', # ID 重复
'HDR ID LEN FOO CRC', # 未知令牌
'', # 空
'HDR ID LEN PAYLOAD'): # 缺 CRC
try:
d.FrameLayout.parse(bad)
fails.append(f'layout-not-rejected:{bad}')
print('FAIL 帧构成未拒绝 ->', bad)
except ValueError:
pass
print('非法帧构成拒绝: PASS')
check('大小写/逗号容错', d.FrameLayout.parse('hdr, id, len, payload, crc').tokens,
d.FrameLayout.parse(d.DEFAULT_LAYOUT).tokens)
# 15) 自定义布局模糊往返(乱拆喂入; 有 HDR 令牌的布局回路需 rx_hdr=tx_hdr)
rng2 = random.Random(9527)
for spec in ('AA 55 LEN PAYLOAD CRC', 'HDR LEN ID PAYLOAD CRC',
'HDR LEN PAYLOAD ID CRC'):
cf = d.ProtoConfig(rx_hdr=0xD5, layout=d.FrameLayout.parse(spec))
for _ in range(10):
pl = bytes(rng2.randrange(256) for _ in range(rng2.randrange(41)))
frame = cf.build_frame(pl)
fp = d.FrameParser(cf)
cut = rng2.randrange(len(frame) + 1)
got = fp.feed(frame[:cut]) + fp.feed(frame[cut:])
ok = len(got) == 1 and got[0].payload == pl and got[0].raw == frame
if not ok:
check(f'layout fuzz {spec}', ok, True)
break
print(f'布局模糊往返 3 布局 × 10 帧: {"PASS" if not fails else "见上"}')
print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS')
sys.exit(1 if fails else 0)
+263
View File
@@ -0,0 +1,263 @@
"""GUI 功能自检(offscreen 无窗口): 组帧预览/TX回显/帧日志/通道提取/显示切换/
未连接丢弃/配置往返
====================================================================
不依赖硬件与串口, 直接驱动 Window 的事件处理函数; 改 main.py 后建议重跑。
用法: python selftest_gui.py (全部通过退出码 0)
"""
import os
import re
import sys
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from PySide6.QtWidgets import QApplication
import config
import main as m
import protocol as d
from plot import fmt_val
fails = []
def check(name, got, want):
ok = got == want
print(('PASS' if ok else 'FAIL'), name, '->', repr(got)[:120],
'' if ok else f'(期望 {want!r})')
if not ok:
fails.append(name)
# 0) 数值显示格式: 普通小数千分位(科学计数只留给 10 亿以上)
check('fmt_val 千分位', fmt_val(28210029), '28,210,029')
check('fmt_val 小数', fmt_val(24.6), '24.6')
check('fmt_val 负小数', fmt_val(-0.25), '-0.25')
check('fmt_val 零', fmt_val(0), '0')
app = QApplication(sys.argv)
win = m.Window()
win.show() # offscreen 显示, isVisible 类断言前提
check('倍率/偏移/序号框宽度(防数值裁切回归)',
win._ch_ui[0]['scale'].width() >= 130
and win._ch_ui[0]['off'].width() >= 110
and win._ch_ui[0]['idx'].width() >= 110, True)
win._apply_minimum_size()
check('窗口最小高度(防曲线卡叠压)', win.minimumHeight() >= 800, True)
# 0b) 曲线时间窗换算(纯计算; 窗口不向数据收敛——选 5min 就显示 5min 宽的轴)
p = win.plot
p.set_view(10)
p.set_offset(0)
check('时间窗跟随右缘', p.visible_domain(0, 100) == (90.0, 100.0), True)
p.set_offset(30)
check('时间窗滞后回看', p.visible_domain(0, 100) == (60.0, 70.0), True)
p.set_offset(90)
check('时间窗滑到最旧', p.visible_domain(0, 100) == (0.0, 10.0), True)
p.set_offset(500)
check('滞后超史为空窗(保宽)', p.visible_domain(0, 100) == (-410.0, -400.0), True)
p.set_view(300)
p.set_offset(0)
check('数据不足窗宽不收敛', p.visible_domain(0, 23) == (-277.0, 23.0), True)
p.set_view(None)
p.set_offset(0)
check('跟随全部', p.visible_domain(0, 100) == (0.0, 100.0), True)
# 0c) 悬停读值: 最近采样命中/容差外未命中
import time as _time
base = _time.perf_counter()
p._times[0].clear()
p._vals[0].clear()
p._names[0] = '甲'
for tt, vv in ((0.0, 10.0), (1.0, 20.0), (2.0, 30.0)):
p._times[0].append(base + tt)
p._vals[0].append(vv)
check('悬停命中最近点', p._nearest(0, base + 1.9, 0.2) == ('甲', 30.0), True)
check('悬停容差外未命中', p._nearest(0, base + 1.4, 0.2) is None, True)
lo, hi = p.auto_yrange()
check('自动Y量程', abs(lo - 8.4) < 1e-9 and abs(hi - 31.6) < 1e-9, True)
from PySide6.QtCore import QPoint
p.resize(600, 300) # 未走布局, 手动给几何
lines = p._hover_lines_at(QPoint(320, 150)) # x=绘图区正中 → t=base+1.0
check('悬停框内容(位置固定也随数据刷新)',
lines[:2] == [f'T = {base + 1.0:.2f}s', '甲 = 20'], True)
# 0d) 暂停渲染 vs 暂停采集: 冻结期间继续收数, 解冻后数据都在
p.set_frozen(True)
n0 = len(p._times[0])
p.add(0, 42.0)
check('冻结期间继续收数', p.is_frozen() and len(p._times[0]) == n0 + 1, True)
p.set_frozen(False)
p.set_paused(True)
n1 = len(p._times[0])
p.add(0, 43.0)
check('暂停期间丢弃', p.is_paused() and len(p._times[0]) == n1, True)
p.set_paused(False)
# 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) 组帧预览: 多条 '/' 分隔
win.ed_tx.setText('M1/M731S0T1000')
check('多条预览', win.lb_preview.text(),
'D5 01 02 4D 31 81 | D5 01 0B 4D 37 33 31 53 30 54 31 30 30 30 8C')
win.combo_tx_mode.setCurrentIndex(1)
win.ed_tx.setText('D5 01 02 4D 31 81')
check('HEX预览', win.lb_preview.text(), '→ D5 01 02 4D 31 81')
win.combo_tx_mode.setCurrentIndex(0)
win.ed_tx.setText('M1')
# 1b) 帧构成定制: 无板ID布局 + LEN 口径切换 + 非法输入提示
win.ed_layout.setText('HDR LEN PAYLOAD CRC')
check('帧构成无ID预览', win.lb_preview.text(), 'D5 02 4D 31 80')
win.combo_len_sem.setCurrentIndex(1) # 到帧尾(payload+CRC)
check('帧构成LEN口径预览', win.lb_preview.text(), 'D5 03 4D 31 81')
win.combo_len_sem.setCurrentIndex(0)
win.ed_layout.setText('HDR LEN') # 缺 PAYLOAD/CRC -> 拒绝
check('帧构成非法提示', win.lb_preview.text().startswith('⚠'), True)
check('帧构成非法详情', 'PAYLOAD 必须恰好出现一次' in win.lb_proto.text(), True)
win.ed_layout.setText(d.DEFAULT_LAYOUT)
check('帧构成还原预览', win.lb_preview.text(), 'D5 01 02 4D 31 81')
# 2) TX 回显行(直接驱动处理函数, 断言前 flush 批量日志缓冲)
cfg = win._proto_from_ui()
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)
# 3) 帧事件: 日志 + 通道提取(文本数字×0.1)
win.combo_display.setCurrentIndex(0)
fr = d.FrameParser(d.ProtoConfig()).feed(
bytes.fromhex('B5 F0 08 6F 6B 54 4C 3A 32 34 36 48'))[0]
win._ch_ui[0]['chk'].setChecked(True)
win._ch_ui[0]['mode'].setCurrentIndex(1) # 文本数字
win._ch_ui[0]['idx'].setValue(1)
win._ch_ui[0]['scale'].setValue(0.1)
win._on_frame(fr)
win._flush_rx()
last = win.text_rx.toPlainText().splitlines()[-1]
check('帧日志含 TYPE 名', 'B=0xF0 成功' in last and 'LEN=8' in last
and 'okTL:246' in last, True)
check('通道最新值', win._ch_ui[0]['val'].text(), '24.6')
# 时间戳用 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 显示切换 + 原始帧
win.combo_display.setCurrentIndex(1)
win._on_frame(fr)
win._flush_rx()
last = win.text_rx.toPlainText().splitlines()[-1]
check('HEX显示+原始帧', '6F 6B 54 4C 3A 32 34 36' in last
and '[B5 F0 08 6F 6B 54 4C 3A 32 34 36 48]' in last, True)
# 5) 透传行
win.combo_display.setCurrentIndex(1)
win._on_raw(b'\x11\x22')
win._flush_rx()
check('透传HEX行', win.text_rx.toPlainText().splitlines()[-1].endswith('11 22'), True)
# 6) 未连接发送丢弃
win._send()
win._drain_events()
check('未连接发送提示', '未连接串口, 命令被丢弃' in win.text_rx.toPlainText(), True)
# 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) 配置往返
store = win._store_from_ui()
check('配置 proto', (store['proto']['tx_hdr'], store['proto']['crc_algo']),
('D5', 'SUM8'))
check('配置帧构成', (store['proto']['layout'], store['proto']['len_sem']),
(d.DEFAULT_LAYOUT, 'payload'))
check('配置通道1', (store['channels'][0]['enabled'], store['channels'][0]['scale']),
(True, 0.1))
# 8) 板型档案: 保存/切换应用协议参数/存入命令/清理
check('板型档案加载', win.combo_profile.count() >= 1, True)
win.ed_board.setText('03')
check('存为板型', win._save_profile_as('测试板'), True)
names = [win.combo_profile.itemText(i) for i in range(win.combo_profile.count())]
check('板型入列', '测试板' in names, True)
pidx = names.index('测试板')
win.combo_profile.setCurrentIndex(pidx)
check('切换板型应用板ID', win.ed_board.text(), '03')
win.combo_profile.setCurrentIndex(0)
check('切回默认板ID', win.ed_board.text(), '01')
win.ed_tx.setText('M1')
win._add_command_named('M1', 'M1')
check('存入命令去重', any(c[1] == 'M1' for c in win._commands)
and len([c for c in win._commands if c[1] == 'M1']) == 1, True)
check('预设显示名命令', win.combo_preset.itemText(0) == '握手 · M1', True)
# 10) 新建板型(命令预设从空白开始) + 命令预设表编辑器
check('新建板型', win._new_profile_named('新建测试板'), True)
check('重名新建拒绝', win._new_profile_named('新建测试板'), False)
check('新建板型命令为空', win._commands == [], True)
dlg = m.ProfileEditDialog(win.profiles[win.combo_profile.currentIndex()])
from PySide6.QtWidgets import QTableWidgetItem
for r, (n, c) in enumerate((('甲', 'A1'), ('乙', 'B2'), ('丙', 'C3'))):
dlg.table._add_row()
dlg.table.setItem(r, 1, QTableWidgetItem(n))
dlg.table.setItem(r, 2, QTableWidgetItem(c))
check('编辑器收集', dlg.commands() == [['甲', 'A1'], ['乙', 'B2'], ['丙', 'C3']],
True)
dlg.table.move_row(0, 2) # 拖动排序: 首行挪到末尾
check('拖动换序', dlg.commands() == [['乙', 'B2'], ['丙', 'C3'], ['甲', 'A1']],
True)
check('手柄列存在', dlg.table.item(0, 0).text() == '≡', True)
dlg.table._add_row() # 重复命令与空命令行均被忽略
dlg.table.setItem(3, 1, QTableWidgetItem('重复'))
dlg.table.setItem(3, 2, QTableWidgetItem('B2'))
check('编辑器去重空行', dlg.commands() == [['乙', 'B2'], ['丙', 'C3'], ['甲', 'A1']],
True)
# 11) 删除板型: 核函数删除/不存在拒绝/至少保留一个
check('删除板型', win._delete_profile('新建测试板'), True)
check('删除后不在列', all(p['name'] != '新建测试板' for p in win.profiles), True)
check('删除不存在拒绝', win._delete_profile('不存在的板型'), False)
# 清理运行态测试板型
win.profiles[:] = [p for p in win.profiles if p['name'] != '测试板']
config.save_profiles(win.profiles)
win._reload_profiles()
check('清理测试板型', all(p['name'] != '测试板' for p in win.profiles), True)
# 9) 曲线弹出/嵌回
win._toggle_plot_window()
check('曲线弹出', win._plot_win is not None
and win.plot.window() is win._plot_win, True)
win._toggle_plot_window()
check('曲线嵌回', win._plot_win is None and win.plot.parent() is not None, True)
win._pop_plot()
win._dock_plot()
check('弹出后关窗嵌回', win._plot_win is None and win.plot.isVisible(), True)
win.link.close()
print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS')
sys.exit(1 if fails else 0)
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""链路自检(无硬件): pyserial loop:// 虚拟回环验证 组帧→发送→接收→解析→曲线提取
====================================================================
缺 pyserial 时跳过(退出码 0)。loop:// 回环口写即读, 发出的帧同样回到
接收侧, 但 TX 帧头(D5)不是 RX 帧头(B5), 会被解析器当噪声丢弃, 不影响判定。
用法: python selftest_loop.py (全部通过退出码 0)
"""
import sys
import time
try:
import serial # noqa: F401
except ImportError:
print('未安装 pyserial, 跳过回环自检')
sys.exit(0)
import protocol as d
from link import SerialLink
fails = []
def check(name, got, want):
ok = got == want
print(('PASS' if ok else 'FAIL'), name, '->', got, '' if ok else f'(期望 {want})')
if not ok:
fails.append(name)
def wait_for(pred, timeout=5.0):
deadline = time.time() + timeout
while time.time() < deadline:
if pred():
return True
time.sleep(0.05)
return False
got = {'status': [], 'frames': [], 'tx': [], 'raw': []}
cfg = d.ProtoConfig()
link = SerialLink(cfg, on_status=lambda s, ts: got['status'].append((s, ts)))
link.on_frame = lambda fr, ts: got['frames'].append((fr, ts))
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) 打开虚拟回环口
link.open('loop://', 115200)
check('连接 loop://', wait_for(lambda: got['status'] and
got['status'][-1][0].startswith('已连接')), True)
check('状态事件携带时刻', isinstance(got['status'][-1][1], float), True)
# 2) 自动包装发送: TX 回显 + 回环字节
payload = 'M731S0T1000'
frame = cfg.build_frame(payload.encode('ascii'))
link.send_frame(frame, label=payload)
check('发送回显', wait_for(lambda: len(got['tx']) >= 1), True)
if got['tx']:
check('TX label', got['tx'][0][0], payload)
check('TX frame', got['tx'][0][1], frame)
check('TX 携带写入时刻', isinstance(got['tx'][0][2], float), True)
# 3) 模拟设备应答: okTL:246(温度 24.6°C) → 解析 + 曲线提取
resp = bytes.fromhex('B5 F0 08 6F 6B 54 4C 3A 32 34 36 48') # sum(F0..36)=0x48
check('响应帧向量自校验', cfg.verify_frame(resp), True)
link.ser.write(resp)
check('应答解析', wait_for(lambda: len(got['frames']) >= 1), True)
if got['frames']:
fr, fts = got['frames'][0]
check('帧携带到达时间戳', isinstance(fts, float)
and 0 < time.time() - fts < 60, True)
check('TYPE 码', fr.code, 0xF0)
check('payload', fr.payload, b'okTL:246')
v = d.extract_value(fr.payload, 'num', index=1, scale=0.1)
check('曲线提取(×0.1)', v is not None and abs(v - 24.6) < 1e-9, True)
# 4) 透传模式切换: 关解析后收到原始字节
link.set_parse(False)
raw = bytes([0x11, 0x22, 0xB5, 0x00])
link.ser.write(raw)
check('透传原始字节', wait_for(lambda: len(got['raw']) >= 1), True)
if got['raw']:
check('透传内容', got['raw'][0][0], raw)
check('透传携带到达时间戳', isinstance(got['raw'][0][1], float), True)
# 5) 切回解析模式, 缓冲已清空, 正常再收一帧
link.set_parse(True)
link.ser.write(bytes.fromhex('B5 F0 02 6F 6B CC'))
check('切回解析再收帧', wait_for(lambda: len(got['frames']) >= 2), True)
link.close()
check('断开', wait_for(lambda: got['status'] and got['status'][-1][0] == '已断开'), True)
print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS')
sys.exit(1 if fails else 0)
+129
View File
@@ -0,0 +1,129 @@
"""Theme persistence and Fluent Widgets integration."""
import ctypes
import os
from PySide6.QtCore import QObject, QSettings, Qt
from PySide6.QtGui import QColor, QPalette
from qfluentwidgets import Theme, setTheme, setThemeColor
# Fluent widgets restyle themselves on setTheme(); QMainWindow, the native
# QTabWidget/QStatusBar and the Windows title bar keep the OS palette unless
# the palette, an app stylesheet and the DWM title bar attributes follow suit
# (same approach as DLPilot app_fluent.py).
_SURFACES = {
False: {"window": "#f4f6f8", "base": "#ffffff", "text": "#1f2328",
"dim": "#57606a", "border": "#d8dee4", "selected_text": "#ffffff"},
True: {"window": "#202020", "base": "#2b2b2b", "text": "#f5f5f5",
"dim": "#9a9a9a", "border": "#3a3a3a", "selected_text": "#1f2328"},
}
class ThemeManager(QObject):
"""Keep the appearance choice persistent and synchronized with Qt."""
MODES = {"system", "light", "dark", "dark1", "dark2"}
_THEMES = {
"system": Theme.AUTO,
"light": Theme.LIGHT,
"dark": Theme.DARK,
"dark1": Theme.DARK,
"dark2": Theme.DARK,
}
_ACCENTS = {
"light": "#0f6cbd",
"dark": "#f2c94c",
"dark1": "#5aa9e6",
"dark2": "#35c6c8",
}
def __init__(self, app):
super().__init__(app)
self.app = app
self.window = None
self.settings = QSettings("LT8619C-Tools", "SerialMate")
mode = self.settings.value("appearance/theme", "system")
self.mode = mode if mode in self.MODES else "system"
self.apply()
app.styleHints().colorSchemeChanged.connect(self.apply)
def set_window(self, window):
"""Register the main window so the native title bar follows the theme."""
self.window = window
self._apply_titlebar()
def set_mode(self, mode):
if mode not in self.MODES:
raise ValueError(f"Unknown theme: {mode}")
self.mode = mode
self.settings.setValue("appearance/theme", mode)
self.apply()
def is_dark(self):
if self.mode in {"dark", "dark1", "dark2"}:
return True
return self.mode == "system" and self.app.styleHints().colorScheme() == Qt.ColorScheme.Dark
def apply(self, *_):
is_dark = self.is_dark()
setTheme(self._THEMES[self.mode])
setThemeColor(self._ACCENTS.get(self.mode, self._ACCENTS["light"]))
self._apply_palette(is_dark)
self._apply_titlebar()
def _apply_palette(self, is_dark):
"""Give native Qt surfaces the same colors as the Fluent widgets."""
surface = _SURFACES[is_dark]
accent = QColor(self._ACCENTS.get(self.mode, self._ACCENTS["light"]))
palette = QPalette()
for role, color in (
(QPalette.Window, surface["window"]),
(QPalette.Base, surface["base"]),
(QPalette.AlternateBase, surface["base"]),
(QPalette.Text, surface["text"]),
(QPalette.WindowText, surface["text"]),
(QPalette.Button, surface["base"]),
(QPalette.ButtonText, surface["text"]),
(QPalette.PlaceholderText, surface["dim"]),
(QPalette.Highlight, accent),
(QPalette.HighlightedText, surface["selected_text"]),
(QPalette.ToolTipBase, surface["base"]),
(QPalette.ToolTipText, surface["text"]),
):
palette.setColor(role, QColor(color))
self.app.setPalette(palette)
self.app.setStyleSheet(f"""
QMainWindow, QWidget#root {{ background-color: {surface['window']}; color: {surface['text']}; }}
QTabWidget::pane {{ background: {surface['base']}; border: 1px solid {surface['border']}; border-radius: 6px; }}
QTabBar {{ qproperty-drawBase: 0; }}
QTabBar::tab {{ background: transparent; color: {surface['dim']}; padding: 6px 16px;
border: 1px solid transparent; border-top-left-radius: 6px;
border-top-right-radius: 6px; }}
QTabBar::tab:selected {{ background: {surface['base']}; color: {surface['text']};
border: 1px solid {surface['border']}; }}
QTabBar::tab:hover:!selected {{ background: {surface['base']}; }}
QSplitter::handle {{ background: {surface['window']}; }}
QStatusBar {{ background: {surface['window']}; color: {surface['dim']};
border-top: 1px solid {surface['border']}; }}
QToolTip {{ background-color: {surface['base']}; color: {surface['text']};
border: 1px solid {surface['border']}; }}
""")
def _apply_titlebar(self):
"""Ask DWM for a title bar in the current surface color (Win10/11)."""
if self.window is None or os.name != "nt" or not hasattr(ctypes, "windll"):
return
try:
hwnd = int(self.window.winId())
dwm = ctypes.windll.dwmapi
dark = ctypes.c_int(1 if self.is_dark() else 0)
dwm.DwmSetWindowAttribute(hwnd, 20, ctypes.byref(dark), ctypes.sizeof(dark))
surface = _SURFACES[self.is_dark()]
dwm.DwmSetWindowAttribute(hwnd, 35, ctypes.byref(self._colorref(QColor(surface["window"]))), 4)
dwm.DwmSetWindowAttribute(hwnd, 36, ctypes.byref(self._colorref(QColor(surface["text"]))), 4)
except Exception:
pass
@staticmethod
def _colorref(color):
return ctypes.c_int(color.red() | (color.green() << 8) | (color.blue() << 16))
+309
View File
@@ -0,0 +1,309 @@
# agent.md — 项目开发需求与进度管理
> 按 `C:\Users\Lenovo\.zcode\AGENTS.md`(用户级默认指令·默认工作约定)维护:
> 记录项目约定与开发进度——已完成功能清单、已提出但尚未实现的计划——随开发持续更新。
> 工程约束与构建规程在各工具 `AGENTS.md`;使用说明在根与各工具 README。
> 内容以仓库工件为准,每次完成开发或有新需求提出后同步更新本文。
## 1. 项目定位与约定
- 本仓库是**全部调试工具的收纳仓**(起家于 **LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板**
调试配套)。原名 `LT8619C_Debug_Tools`,2026-09-28 更名 `HW-Debug-Tools`:Gitea 已改名
(旧地址自动重定向)、本地目录与 remote 同步更名;Gitea Release 同日清理为各已发布工具
仅保留最新版(DLPilot v0.4.0 / EDID-Viewer v0.1.0 / SerialMate v0.1.0),
旧 release 删除但 tag 与提交历史保留。服务上游固件仓 `hulk/LT8619C_DLPC3421_HDMI`(本机路径
`E:\Hulk-Coding\LT8619C_DLPC3421_HDMI`,同 Gitea 实例 gitea.hulk.wang)。
光机 RS485 协议**唯一权威**是上游仓 `protocols.md`,本仓不维护协议副本。
- 工具链(全仓统一):Python 3.14;GUI 用 PySide6 + PySide6-Fluent-Widgets,解析/协议
模块纯标准库;PyInstaller onefile 打包;发布流水线 build_exe → package_release →
publish_gitea(`GITEA_TOKEN` 用户级环境变量;实例 TLS 间歇抽风,实际请求加
`--insecure`,详见 `DLPilot/AGENTS.md` §5)。
- 结构约定:每个工具三区目录(**src/ 人写 / assets/ 可再生 / release/ 交付**)+ 双文档
(README 使用说明 / AGENTS.md 工程约束与进度)。各工具独立开发、独立打标签发布,
Release 列表按时间倒序混排于同一页面:
- **DLPilot**(原 HDMI-Tool)="3D 光固化光机调试台":HDMI 源端(EDID/分辨率/
测试图案投屏)+ 光机控制(RS485→GD32E230 网关→DLPC3421)+ 电源检查。
- **EDID-viewer**:Windows 显示器 EDID 只读查看器(Fluent UI)。
- **SerialMate**:RS485/串口调试助手,帧格式(包头/板ID/LEN/CRC)可定制,
默认兼容 DLPilot 光机协议;payload 字符串/HEX 显示、4 通道曲线、payload
自动包装发送。
- **I2C-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 总线择机注入读写寄存器。
**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`)
改动后需 robocopy /MIR 同步到 `E:\Hulk-Coding\LT8619C\_Debug\_Tools\EDID-viewer`
(排除 .venv/build/__pycache__/.git,zip 不同步)。
- 图标约定(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.1 DLPilot — 最新发布 v0.4.3(2026-10-13)
- HDMI 源端:EDID 查看/逐字节解析、分辨率切换、智能显示拓扑(仅拉回转接板屏)、
纯色/黑白/测试卡全屏投屏、自定义槽位(pattern_slots.json)。
- 光机控制:RS485 链路(`dlpc_rs485.py` 帧协议+线程)、开关光机/设光强/TEC 控温/
查温度电流,串口参数化。
- 电源检查:PD 自检(M301→M302→M303 状态机)、FPDO 挡位判定、功率检测
(M303+M304+M305 → V/mA/W 两位小数)。
- 界面:PySide6-Fluent-Widgets 双栏仪表盘(v0.4.0 起),Light/Dark/System 主题
(Fluent 主题 + Qt 调色板 + QSS + DWM 标题栏三件套同步)。
- 发布:v0.1.0(HDMI-Tool)→ v0.2.0 → v0.3.x → v0.4.0 均已上传 Gitea Release
(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)
- 解析:EDID 1.x 基础身份/时序、CTA(音频 SAD/HDMI VSDB/CEC/HF-VSDB)、HDR 与
色彩能力、逐块校验与越界诊断(`edid.py` 纯标准库 + 9 项单测,含随机畸形数据
无崩溃检查)。
- 数据获取:Windows WMI 只读枚举活动设备(HDMI/DP/内屏连接类型);离线
`.bin/.edid/.hex/.txt` 导入;BIN 与 JSON 报告导出。
- 界面:Fluent 卡片布局、分组页签(设备与能力/时序/原始 HEX/完整 JSON)、右上角
"关于"(v0.1.0)。
- 主题:Light/Dark/dark1/dark2/跟随系统,三件套同步 + QSettings 持久化 +
系统明暗变化响应。
- 工程化:目录与发布流水线对齐 DLPilot(onefile exe 入库);标签
`EDID-Viewer-v0.1.0` 与 Release 附件(66,483,477 字节)已核对。
### 2.3 SerialMate — v0.1.0(2026-09-27,首版)
- **更名**:原名 SerialAssistant(目录/exe/文档/配置同步更名;旧配置
serial_assistant.json 首次启动自动迁移为 serial_mate.json;更名时的图标为
波形+RS485 母题,后统一为家族版式,见 §2.6)。用户确认 "SerialMate 吧"(2026-09-27)。
- **已发布**:commit d133906 推送 main 并打 tag `SerialMate-v0.1.0`,
Release 附件 `SerialMate-v0.1.0-win64.zip`(63.7 MB)已上传核对。
- 协议:**帧构成令牌序列可定制**(`HDR ID LEN PAYLOAD CRC` + 十六进制字面量
同步头,收发共用布局仅包头分方向;支持字段换序/无板ID/多字节同步头,LEN
口径 payload 长度/到帧尾/整帧三种)、5 种 CRC 算法 × 3 种覆盖范围,默认与
DLPilot 光机 RS485 协议一致(D5/B5/01/SUM8/不含包头);增量解析前缀逐位
对齐 + CRC 失败逐字节重同步;自测 64 项全 PASS(DLPilot 实测帧向量 +
CRC 标准校验值 + 布局换序/LEN 口径向量 + 布局模糊往返)。
- 链路:串口参数热切换、半双工友好发送队列(未连接丢弃)、解析/透传双模式
热切、`loop://` 虚拟回环自检 13 项全 PASS(组帧→发送→接收→解析→曲线提取)。
- GUI(PySide6-Fluent-Widgets,用户指定沿用 Qt 方案):帧构成/LEN 口径可视化
编辑,payload 字符串/HEX 切换(半透明底色高亮)、原始帧 hex、时间戳、日志
保存;4 通道曲线(三种提取 + 倍率,**弹窗独立**:时间窗滑笔回看、纵轴刻度、
悬停读值、暂停渲染/自动Y/滚轮缩放/拖动 Y 轴);发送自动包装(输入 payload
自动组帧,`/` 分隔多条依次发)、HEX 直发、周期发送、组帧实时预览、历史
回溯、回车发送;主题跟随系统;标题栏版本号 + 关于弹窗 + 图标(波形 RS485)。
- 板型档案(2026-09-27 实机反馈追加):不同板子的"协议参数+命令预设表"存为
命名档案(board_profiles.json,不入库,首次运行生成默认 DLPilot 板型),
发送卡一键切换/新建/存为/删除/表格编辑/重载。
- 发布:SerialMate.exe 已入库(随帧构成功能重建),zip/Release 未发布
(待用户验收后走 package_release + publish_gitea)。
- **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)。
### 2.4 I2C-Inject — ⏸ 暂停开发(2026-09-25)
- 已完成:RP2040 注入器固件(MicroPython,内置 viper 总线嗅探)、PC 端 GUI、
demo 板寄存器语义实测(0x606E bit5=镜像 D[7:0]、0x606D 整组顺序,逻辑分析仪
双色交叉验证)。
- 状态:暂停开发,代码与固件保留备查,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.1 DLPilot
- ~~【待素材】校准图案~~(已完成,2026-10-13 `74469cf`):CeramixNano 系列 6 张
校准图(黑/白/caliPoint/circle_640p/contrast/Sharpness_XY_3)二进制直存
assets/+release/ 平铺(用户素材,无生成脚本),slots 栏位接入、
package_release 收集规则扩展;随 v0.4.3 之后的下次发布进 zip。
- 【待真机帧·低优先】M305 回归向量是按协议推演的(selftest_rs485.py:90 起,
0x8CA0=36000mW 为推演值):真机功率显示已实测对接,仅剩把自测向量换成真机抓包,
让无硬件回归更贴近真机。
- 【新需求·已确认可行(2026-09-25)】画面镜像(横/纵独立翻转,任意来源画面):
投影观看方向(正投/背投/吊装)需要源信号预先翻转。DLPC3421 原生支持——I2C 14h
"写入显示图像方向"(b1=长轴翻转、b2=短轴翻转,两轴独立;15h 读回;b0 旋转仅竖排源
且 DLPC3421 不支持)。实施分两段:
- 上游固件(协议权威在上游仓):`dlpc3421.h` 已定义 14h/15h 命令号但驱动未实现——
照 `dlpc3421_set/get_mirror_lock_control` 模式补 set/get 函数,再暴露为 M 命令
(现有 M981~M983 是只读调试命令,可续编 M984=设置/M985=读取,命名以上游为准),
登记进 protocols.md;上电/软重启后是否自动恢复保存的方向由固件决定。
**命名陷阱**:现有 `set/get_mirror_lock_control`(39h"镜片锁定控制")是出厂 DMD
接口锁定校准用,不是图像镜像,勿混用。
- 上位机 DLPilot:`dlpc_rs485.py` 加命令封装,光机卡加"水平镜像/垂直镜像"开关并
读回回显;用方向不对称图案(如 bw_sweep/文字图)实测确认长轴/短轴与横/纵的对应。
- 备选(若固件暂不动):DLPilot 自投图案/自定义图片可在 Qt 渲染时翻转(仅覆盖测试
内容);任意桌面内容 Windows 标准 API 只有 180°(=两轴同时),单方向翻转做不到。
### 3.2 EDID-Viewer
- EDID 对比、字节差异与固件数组导出。
- HDMI Forum VRR/ALLM/FRL/DSC、DisplayID 与高级色彩数据解析(第一版有意不解,
保留原始块供分析)。
- 与实际输出时序和桥接芯片寄存器联合诊断。
- 实机验收缺口:HDMI 热插拔、多屏、跨屏 DPI、最大化/恢复与导入导出完整交互
(见 `EDID-viewer/VALIDATION.md`)。
### 3.3 SerialMate
- 【待真机】光机实机收发与温度曲线实测(M201 ×0.1 提取),当前仅回环验证。
- ~~【无图标】assets/serial-mate.ico 未生成~~(已生成并随家族统一,见 §2.6)。
- 发送批处理脚本化(延时/循环/等待应答);曲线数据导出 CSV。
### 3.4 I2C-Inject
- 并入 I2C-Lab 路线(见 §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 用户确认:当前行为正常、
符合预期,bit5 实测基线已够用;原【待硬件】项关闭)。
- TI 原始 I2C 命令透传不做:GD32 网关固件只收 ASCII M 命令集(v0.2.0 显式决策)。
- DLPilot 不引入 pyserial/Pillow 硬依赖:保持 try-import + HAS_* 标志 + 缺失降级。
- EDID-Viewer 不承诺实时 DDC 抓取:数据来自 Windows/显卡驱动,可能缓存或覆盖。
## 4. 决策史:时间线与踩坑(勿重蹈)
**阶段 0 · 寄存器语义实测期(09-03)**:I2C-Inject + HDMI-Tool 首版落库;0x606E
位交换 / 0x606D 整组顺序经 demo 板 LA 双色交叉实测确立(`la_wave_analyzer*.py`
即该过程产物)。
**阶段 1 · 工程化与发布流水线(09-10)**:确立三区目录 + 双文档;打通
build_exe → package_release → publish_gitea,发布 HDMI-Tool v0.1.0。
**阶段 2 · 光机 RS485 控制集成(09-11,v0.2.0)**:新增 `dlpc_rs485.py`(帧构造/
增量解析/译码表/DLPC485 链路类,模式照搬 I2C-Inject 的 Injector);启动拓扑改智能判定。
**阶段 3 · GUI 重构与产品化(09-12,v0.3.0)**:CustomTkinter 双栏仪表盘
(规格 `UI参考布局.svg`);更名 HDMI-Tool → DLPilot;TEC 控制与温度实时显示。
**阶段 4 · 体验打磨(09-14,v0.3.1~0.3.3)**:投屏退出语义两轮更名;标题栏版本号;
修复 USB 拔出后同参数重连失效。
**阶段 5 · 电源检查卡(09-17~18,v0.3.4~0.3.6)**:电源自检状态机、功率检测、
M30x 二进制载荷封装(详见踩坑 1~3、10)。
**阶段 6 · DLPilot Fluent UI 重写(09-24,v0.4.0)**:PySide6-Fluent-Widgets 替换
CustomTkinter;补齐折叠、PD 状态颜色、Light 调色板与 Qt 单文件 DLL 加载。
**阶段 7 · EDID-Viewer 独立成工具(09-23~25,v0.1.0)**:从 DLPilot 的源端 EDID
需求独立;目录与流水线对齐;同期 I2C-Inject 标记暂停开发,根 README/agent.md
按三工具更新。
| # | 问题 | 最初做法 → 最终做法 / 教训 |
|---|---|---|
| 1 | 电源自检成功判定 | 先按 ASCII `ok` 前缀判成功 → 实机 M301 回包 payload=`PD` 无前缀被误判错误(9772a2b)→ **改按帧 TYPE=0xF0 译码分类**。教训:M30x 系列回包不能按文本判 |
| 2 | 功率值来源 | 先用 M303×M304 计算 P=UI 占位显示(2b7d103)→ 固件提供 M305 直读 u32 mW 后改为三步序列直读(9b29b6a) |
| 3 | 二进制载荷三连坑 | ① `Frame.payload` 解出乱码 → 用 `payload_bytes()` 取原始字节;② M305 是 LEN=4,复用双字节解析会错 → 按 kind 校验长度;③ 日志行含 NUL 复制被截断 → 二进制载荷日志行以 hex 展示(e354423) |
| 4 | "串口断不开" | 表象是断开逻辑 bug,实际根源是**双实例互抢串口** → 单实例锁 + 串口占用提示(e87bf14) |
| 5 | 窗口最大化卡顿 | CTkScrollableFrame+40 余个逐行 Label 最大化逐控件重绘 → 换 CTkTextbox 整体重排,开销低一个量级(30bc224) |
| 6 | 启动强制扩展拓扑 | 无条件 `SetDisplayConfig` 会打扰用户自选拓扑 → 仅当未激活显示器 EDID 厂商字节为转接板签名 `61 93`("XLS") 时才拉回(d9ecff4) |
| 7 | GUI 框架 | tkinter → CustomTkinter(84838e3)→ v0.4.0 PySide6-Fluent-Widgets;旧文件仅供历史对照 |
| 8 | 投屏退出按钮文案 | "恢复桌面"→"停止投屏"→"停止测试图像"(3f35ad2→b167ce3):最终语义是关测试图窗口,与拓扑恢复无关 |
| 9 | USB 拔出后重连失效 | 掉线分支未清 `_open_key`,重连被"参数未变"跳过 → 掉线同步清缓存(51e2f1c) |
| 10 | 电源自检卡死 | 帧路由引用未定义变量 `payload`(3f36ead),改名/重构时注意帧回调作用域 |
| 11 | Gitea 发布 | urllib 间歇 `SSL: UNEXPECTED_EOF` → 必须 `--insecure`;`/releases/tags/{tag}` 的 assets 是纯数组而 `/releases/{id}` 是 `{items:[...]}`,publish_gitea.py 已兼容两种形状,勿"简化";自动重试已加(f0f8073) |
| 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 |
| 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)
```powershell
# DLPilot(完整清单:DLPilot/AGENTS.md §5/§7)
cd DLPilot/src
python -m compileall -q . ; python selftest_rs485.py # 编译检查 + 光机协议回归
python hdmi_gui.py --seconds 5 # GUI 冒烟
python build_exe.py ; python package_release.py 0.4.0 # 打包 + 组装 zip
python publish_gitea.py ../release/DLPilot-v0.4.0-win64.zip --replace --insecure
# EDID-Viewer(完整清单:EDID-viewer/AGENTS.md)
cd EDID-viewer/src
python -m unittest # 9 项解析测试(改 edid.py 后必跑)
python build_exe.py ; python package_release.py 0.1.0 # 打包 + 组装 zip
python publish_gitea.py ../release/EDID-Viewer-v0.1.0-win64.zip --insecure
# SerialMate(完整清单:SerialMate/AGENTS.md)
cd SerialMate/src
python selftest.py ; python selftest_loop.py # 协议回归 + 链路回环(改协议/链路后必跑)
python main.py --seconds 5 # GUI 冒烟
python build_exe.py ; python package_release.py 0.1.0 # 打包 + 组装 zip
python publish_gitea.py ../release/SerialMate-v0.1.0-win64.zip --insecure
# 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/`;
本仓改动约束以两个工具的 AGENTS.md 为准——**其中领域语义的测试值
(55/AA/FF、640x360、800x600@60)有明确用途,勿顺手改**。