Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
955e56efef | ||
|
|
72b7aee372 | ||
|
|
8e91e661f1 | ||
|
|
ac76c10132 | ||
|
|
7ba01eae5e | ||
|
|
42ff258b35 | ||
|
|
74469cfe66 | ||
|
|
2a9a5f5b96 | ||
|
|
e5b2e45f57 | ||
|
|
296e220f20 | ||
|
|
e3ef359df4 | ||
|
|
be32efa806 | ||
|
|
8fde79c071 | ||
|
|
b4328b8be6 | ||
|
|
4149d203b0 | ||
|
|
264a0c74a6 | ||
|
|
190cf29aa5 | ||
|
|
d658fd459a | ||
|
|
b627f79dad | ||
|
|
c26d2cdbaa | ||
|
|
372cb6b7a0 | ||
|
|
68fb81fb13 | ||
|
|
733847a58c | ||
|
|
f594f60fdd | ||
|
|
e92fa9f349 | ||
|
|
fe4bca5a33 | ||
|
|
b58cdc42b6 | ||
|
|
21c1b7bcc1 | ||
|
|
144730fb3a | ||
|
|
ca898be299 | ||
|
|
511e7dfa44 | ||
|
|
6d1e2c6f8d | ||
|
|
a0d64c322a | ||
|
|
a666ed7c43 | ||
|
|
1f68665860 | ||
|
|
668a7e1ca5 | ||
|
|
bf9d70722c | ||
|
|
5d06e69a41 | ||
|
|
a3430d15e0 | ||
|
|
419c902a38 | ||
|
|
b1bb320b50 | ||
|
|
2d1d8f83b1 | ||
|
|
d991d57447 |
@@ -2,13 +2,34 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
# PyInstaller 工作目录(打包产物 exe 已直接入库, 构建中间物不入库)
|
# PyInstaller 工作目录(构建中间物不入库)
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
*.spec
|
*.spec
|
||||||
|
|
||||||
|
# exe 一律不入库(2026-10-13 起): 交付物只走 Gitea Release 附件, 本地保留。
|
||||||
|
# 历史 39 个 exe blob 已用 git filter-repo 清除(仓库 1.1 GiB -> ~1 MB (pack 实测))
|
||||||
|
*.exe
|
||||||
|
|
||||||
# 发布 zip: 可由 src/package_release.py 重新生成, 只上传 Gitea Release
|
# 发布 zip: 可由 src/package_release.py 重新生成, 只上传 Gitea Release
|
||||||
DLPilot/release/*.zip
|
DLPilot/release/*.zip
|
||||||
|
EDID-viewer/release/*.zip
|
||||||
|
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 客户端本地会话数据(计划留档等), 非项目配置
|
||||||
.zcode/
|
.zcode/
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
# AGENTS.md — DLPilot 工程档案(供 AI/Agent 使用)
|
# AGENTS.md — DLPilot 工程档案(开发约束与进度)
|
||||||
|
|
||||||
> **本文档面向 AI/Agent**,是人类文档 [README.md](README.md) 的姊妹篇,两者不重叠:
|
> 人类文档 [README.md](README.md) 回答"这是什么、怎么用";本文记录"代码怎么组织、
|
||||||
> README 回答"这是什么、怎么用";本文回答"代码怎么组织、怎么构建发布、改动时哪些约束不能破坏"。
|
> 怎么构建发布、改动时哪些约束不能破坏",兼作本工具的进度记录。
|
||||||
> 任何 Agent 在修改本目录代码前应先读完本文。
|
> 修改本目录代码前先读完本文。仓库整体的开发需求与进度管理见根目录 `agent.md`。
|
||||||
|
|
||||||
## 1. 项目定位
|
## 1. 项目定位
|
||||||
|
|
||||||
仓库 `LT8619C_Debug_Tools` 是 LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板的**调试上位机工具集**,
|
仓库 `HW-Debug-Tools` 是 LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板的**调试上位机工具集**,
|
||||||
上游主仓库(固件)为 `hulk/LT8619C_DLPC3421_HDMI`(同 Gitea 实例 `gitea.hulk.wang`),
|
上游主仓库(固件)为 `hulk/LT8619C_DLPC3421_HDMI`(同 Gitea 实例 `gitea.hulk.wang`),
|
||||||
本仓库本机路径 `E:\Hulk-Coding\LT8619C_DLPC3421_HDMI`。
|
上游固件本机路径 `E:\Hulk-Coding\LT8619C_DLPC3421_HDMI`;工具仓库路径 `E:\Hulk-Coding\HW-Debug-Tools`。
|
||||||
DLPilot 工作在两端:HDMI **源端**(PC 显卡侧,EDID/分辨率/图案)与**光机控制端**
|
DLPilot 工作在两端:HDMI **源端**(PC 显卡侧,EDID/分辨率/图案)与**光机控制端**
|
||||||
(RS485 → 板载 GD32E230 网关 → DLPC3421)。寄存器语义判读基线(0x606E 位交换 /
|
(RS485 → 板载 GD32E230 网关 → DLPC3421)。寄存器语义判读基线(0x606E 位交换 /
|
||||||
0x606D 整组顺序)与光机协议规程都在上游仓库 `doc/architecture/` 与 `protocols.md`,不在本仓库。
|
0x606D 整组顺序)与光机协议规程都在上游仓库 `doc/architecture/` 与 `protocols.md`,不在本仓库。
|
||||||
@@ -34,15 +34,17 @@ DLPilot/
|
|||||||
|
|
||||||
| 文件 | 角色 |
|
| 文件 | 角色 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `src/hdmi_gui.py` | 入口: argparse 命令行分发(--dump/--edid-fields/--test-pattern/--dlpc-cmd/--seconds), GUI 委托 app_gui |
|
| `src/hdmi_gui.py` | 入口: argparse 命令行分发(--dump/--edid-fields/--test-pattern/--dlpc-cmd/--seconds), GUI 委托 app_fluent |
|
||||||
| `src/app_gui.py` | CustomTkinter 主界面: 双栏仪表盘(左 HDMI 源端五卡/右 光机四卡), 光强/定时快捷滑条 |
|
| `src/app_fluent.py` | PySide6-Fluent-Widgets 主界面: 双栏仪表盘(左 HDMI 源端五卡/右 光机五卡), 光强/定时快捷滑条 |
|
||||||
| `src/display_win.py` | Win32 显示器枚举/分辨率切换/智能拓扑(纯 ctypes) |
|
| `src/display_win.py` | Win32 显示器枚举/分辨率切换/智能拓扑(纯 ctypes) |
|
||||||
| `src/edid_parse.py` | EDID 1.4/CEA-861 解析(纯标准库, 上游 lt8619c_edid.c 以此为参考解码器) |
|
| `src/edid_parse.py` | EDID 1.4/CEA-861 解析(纯标准库, 上游 lt8619c_edid.c 以此为参考解码器) |
|
||||||
| `src/patterns.py` | 测试图案全屏投屏窗口(tkinter, ESC 退出, 测试卡区域 1:1 居中) |
|
| `src/patterns.py` | 测试图案全屏投屏窗口(tkinter, ESC 退出, 测试卡区域 1:1 居中) |
|
||||||
| `src/config.py` | 运行目录定位(BASE_DIR)与 pattern_slots.json 读写(slots/area/serial 三键) |
|
| `src/config.py` | 运行目录定位(BASE_DIR)与 pattern_slots.json 读写(slots/area/serial 三键) |
|
||||||
| `src/dlpc_rs485.py` | 光机 RS485 协议模块: 帧构造/增量解析/译码表/预设命令/串口线程(DLPC485) |
|
| `src/dlpc_rs485.py` | 光机 RS485 协议模块: 帧构造/增量解析/译码表/预设命令/串口线程(DLPC485) |
|
||||||
| `src/selftest_rs485.py` | 光机协议自检(对照上游实测帧向量, 无硬件回归, 改协议代码后必跑) |
|
| `src/selftest_rs485.py` | 光机协议自检(对照上游实测帧向量, 无硬件回归, 改协议代码后必跑) |
|
||||||
| `src/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/package_release.py` | 复制图案到 release/(平铺) → `release/DLPilot-v<ver>-win64.zip`(校验栏位引用完整性; release/ 与 zip 内容一致) |
|
||||||
| `src/publish_gitea.py` | 建/复用 Gitea Release 并上传 zip(tag 默认取 zip 文件名) |
|
| `src/publish_gitea.py` | 建/复用 Gitea Release 并上传 zip(tag 默认取 zip 文件名) |
|
||||||
| `src/hdmi_source.ps1/.bat` | 独立 PowerShell 命令行工具(自包含,与 GUI 无代码共享) |
|
| `src/hdmi_source.ps1/.bat` | 独立 PowerShell 命令行工具(自包含,与 GUI 无代码共享) |
|
||||||
@@ -51,17 +53,18 @@ DLPilot/
|
|||||||
| `src/la_wave_analyzer*.py` | 逻辑分析仪 CSV 波形判读(2/3 通道,40MHz 像素时钟假设) |
|
| `src/la_wave_analyzer*.py` | 逻辑分析仪 CSV 波形判读(2/3 通道,40MHz 像素时钟假设) |
|
||||||
| `src/logos/make_ico.py` | svg/png → ico(写在其自身目录) |
|
| `src/logos/make_ico.py` | svg/png → ico(写在其自身目录) |
|
||||||
| `release/pattern_slots.json` | 预置栏位:西门子星/波带片/综合测试卡,area=640x360;`serial` 键记忆光机串口 |
|
| `release/pattern_slots.json` | 预置栏位:西门子星/波带片/综合测试卡,area=640x360;`serial` 键记忆光机串口 |
|
||||||
| `UI参考布局.svg/.png` | 界面布局设计稿(app_gui.py 双栏布局的规格来源, 随分支入库) |
|
| `UI参考布局.svg/.png` | 界面布局设计稿(app_fluent.py 双栏布局的规格来源, 随分支入库) |
|
||||||
|
|
||||||
## 3. 代码不变量(改 hdmi_gui.py 必须维持)
|
## 3. 代码不变量(改 hdmi_gui.py 必须维持)
|
||||||
|
|
||||||
1. **依赖分层**:CLI/显示/EDID/协议/配置模块(display_win/edid_parse/patterns/config/
|
1. **依赖分层**:CLI/显示/EDID/协议/配置模块(display_win/edid_parse/config/
|
||||||
dlpc_rs485)纯标准库;customtkinter 是 GUI(app_gui)的**硬依赖**(装了才能开界面,
|
dlpc_rs485)纯标准库;PySide6-Fluent-Widgets 是 GUI(app_fluent)的**硬依赖**(装了
|
||||||
CLI 不受影响)。可选依赖仍走"try-import + HAS_* 标志 + 缺失降级"模式:Pillow、
|
才能开界面,CLI 不受影响),GUI 图案窗口使用 Qt,CLI 图案窗口由 patterns.py 使用 tkinter 创建。可选依赖
|
||||||
pyserial——缺失时对应功能禁用/提示安装,其余功能必须照常工作。
|
仍走"try-import + HAS_* 标志 + 缺失降级"模式:Pillow、pyserial——缺失时对应功能
|
||||||
2. **模块边界**:hdmi_gui.py 只是入口, 不放实现; GUI 不直接碰 ctypes/winreg
|
禁用/提示安装,其余功能必须照常工作。
|
||||||
(显示器走 display_win, EDID 走 edid_parse); pyserial 只准在 dlpc_rs485.py 里 import
|
2. **模块边界**:hdmi_gui.py 只是入口, 不放实现; GUI 的显示器/EDID 访问走 display_win/edid_parse;ctypes 仅用于
|
||||||
(hdmi_gui/app_gui 经 `dlpc_rs485.HAS_SERIAL` 与 `DLPC485` 类间接使用)。
|
单实例锁、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)时
|
3. **BASE_DIR 语义**(定义在 config.py):frozen(PyInstaller onefile)时
|
||||||
`BASE_DIR=exe 所在目录`(`sys.executable`),源码运行时 `=config.py 所在目录`
|
`BASE_DIR=exe 所在目录`(`sys.executable`),源码运行时 `=config.py 所在目录`
|
||||||
(即 src/)。所有用户数据(pattern_slots.json、自定义图片)都写/读自 BASE_DIR
|
(即 src/)。所有用户数据(pattern_slots.json、自定义图片)都写/读自 BASE_DIR
|
||||||
@@ -80,17 +83,25 @@ DLPilot/
|
|||||||
9. **EDID 来源是注册表**(`HKLM\SYSTEM\CurrentControlSet\Enum\DISPLAY\...\Device Parameters\EDID`),
|
9. **EDID 来源是注册表**(`HKLM\SYSTEM\CurrentControlSet\Enum\DISPLAY\...\Device Parameters\EDID`),
|
||||||
不是 WMI/DDC;exe 的 noconsole 版无 stdout,`--dump/--edid-fields` 只在源码/控制台版可用。
|
不是 WMI/DDC;exe 的 noconsole 版无 stdout,`--dump/--edid-fields` 只在源码/控制台版可用。
|
||||||
10. **光机串口线程回调不直接碰 GUI**:DLPC485 的 on_event/on_status 在后台线程触发,
|
10. **光机串口线程回调不直接碰 GUI**:DLPC485 的 on_event/on_status 在后台线程触发,
|
||||||
必须经 `queue.Queue` + `root.after(80, …)` 轮询投递回主线程(现行 `_poll_dlpc` 模式,
|
必须经 `queue.Queue` + Qt 定时器轮询投递回主线程(现行 `_poll_dlpc` 模式,
|
||||||
与 I2C-Inject 的 Injector 同源);退出路径(关窗与 `--seconds` 自动关)都会走到
|
与 I2C-Inject 的 Injector 同源);退出路径(关窗与 `--seconds` 自动关)都会走到
|
||||||
`App.destroy()`,在那里 `dlpc.close()` 收线程。
|
`App.closeEvent()`,在那里 `dlpc.close()` 收线程。
|
||||||
11. **pattern_slots.json 格式**:`{'area', 'slots', 'serial'}` 三键共存;load 兼容旧版
|
11. **pattern_slots.json 格式**:`{'area', 'slots', 'serial'}` 三键共存;load 兼容旧版
|
||||||
纯数组(无 area)与无 serial 键两种历史格式,save 永远写全三键。`serial` 子键为
|
纯数组(无 area)与无 serial 键两种历史格式,save 永远写全三键。`serial` 子键为
|
||||||
`{port, baud, bytesize(8/7/6/5), parity(N/E/O), stopbits(1/2)}`,旧配置缺子键按
|
`{port, baud, bytesize(8/7/6/5), parity(N/E/O), stopbits(1/2)}`,旧配置缺子键按
|
||||||
默认 8-N-1 补齐。
|
默认 8-N-1 补齐。
|
||||||
12. **GUI = app_gui.py 双栏仪表盘**(规格:`DLPilot/UI参考布局.svg`):左列 HDMI 源端
|
12. **GUI = app_fluent.py 双栏仪表盘**(规格:`DLPilot/UI参考布局.svg`):左列 HDMI 源端
|
||||||
五卡(显示器/模式/EDID 信息/测试图案/hex),右列光机四卡(串口/数据/命令/滑条),
|
五卡(显示器/模式/EDID 信息/测试图案/hex),右列光机五卡(串口/数据/电源/命令/滑条),
|
||||||
顶栏 Dark/Light/System 切换(ttk.Treeview 颜色随动)。打包必须 `--collect-all
|
支持的模式和 EDID 信息默认折叠;顶栏 Dark/Light/System 切换。Light/Dark 同时更新
|
||||||
customtkinter`(build_exe.py 已内置,勿删)——CTk 主题/字体数据缺失会让 exe 启动即崩。
|
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. 领域语义(测试值都有含义,勿"顺手改")
|
## 4. 领域语义(测试值都有含义,勿"顺手改")
|
||||||
|
|
||||||
@@ -101,11 +112,25 @@ DLPilot/
|
|||||||
**同时是转接板光机 DLPC3421 的 nHD 面板原生分辨率**(该板 shadow EDID 只声明 640x360@60),
|
**同时是转接板光机 DLPC3421 的 nHD 面板原生分辨率**(该板 shadow EDID 只声明 640x360@60),
|
||||||
投 640x360 图案 + RS485 开光机 = 整链路 1:1 验证。
|
投 640x360 图案 + RS485 开光机 = 整链路 1:1 验证。
|
||||||
- bw 系列图案各自目的见 `src/gen_bw_patterns.py` 顶部清单注释。
|
- bw 系列图案各自目的见 `src/gen_bw_patterns.py` 顶部清单注释。
|
||||||
- 光机 RS485 命令集(M730/M731/M732/M201/M202/M737/M999/M888/M7/M8/M1)语义、
|
- 光机 RS485 命令集(M730/M731/M732/M201/M202/M737/M999/M888/M7/M8/M1/**M303/M304**)语义、
|
||||||
帧格式 `D5 01 LEN <ASCII> CRC`(累加和校验)、错误码与**就绪门禁**
|
帧格式 `D5 01 LEN <ASCII> CRC`(累加和校验)、错误码与**就绪门禁**
|
||||||
(DLPC 未就绪时 M730/M731/M732 被拒回 0xFF)以上游 `protocols.md` 为唯一权威,
|
(DLPC 未就绪时 M730/M731/M732 被拒回 0xFF)以上游 `protocols.md` 为唯一权威,
|
||||||
本仓不维护协议文档副本;注意 **M7S0 是"开启"TEC 自动控温**(方向反直觉)、
|
本仓不维护协议文档副本;注意 **M7S0 是"开启"TEC 自动控温**(方向反直觉)、
|
||||||
M737 在定时投光中延迟应答(CLI 等待上限 35s)。
|
M737 在定时投光中延迟应答(CLI 等待上限 35s)。
|
||||||
|
- **M303/M304/M305 载荷是二进制例外**(SQ52205 电源实测):成功载荷为大端原始值,
|
||||||
|
**不是 ASCII**——Frame.payload 解出来是乱码,必须走 `payload_bytes()` 取原始字节;
|
||||||
|
M303=u16 mV(12V 轨窗口 11400~12600),M304=s16 补码 mA(正=正向,固件原样上总线,
|
||||||
|
上位机按有符号解析,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+M305 序列 → 读数框 电压V / 电流mA / 功率W(M305 u32 mW
|
||||||
|
换算两位小数);各步载荷长度按 kind 校验(2/2/4 字节),坏帧中止显示 ERR。
|
||||||
|
|
||||||
## 5. 构建 / 发布流水线(全部在 src/ 下执行)
|
## 5. 构建 / 发布流水线(全部在 src/ 下执行)
|
||||||
|
|
||||||
@@ -114,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 # → ../release/DLPilot.exe(缺 PyInstaller 会自动 pip install)
|
||||||
python build_exe.py --debug # 附加带控制台的 DLPilot-console.exe(调试 --dump 用)
|
python build_exe.py --debug # 附加带控制台的 DLPilot-console.exe(调试 --dump 用)
|
||||||
python package_release.py <版本> # → ../release/DLPilot-v<版本>-win64.zip
|
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/release 命名 `DLPilot-vX.Y.Z`,zip 命名 `DLPilot-vX.Y.Z-win64.zip`(publish_gitea.py
|
||||||
依赖此命名从文件名推 tag);
|
依赖此命名从文件名推 tag);
|
||||||
- **exe、pattern_slots.json 与 release/ 下平铺的图案 png 改动跟随源码/资产生成提交入库;
|
- **pattern_slots.json 与 release/ 下平铺的图案 png 改动跟随源码/资产生成提交入库;
|
||||||
zip 永不入库**(.gitignore 已挡);
|
exe 不入库**(2026-10-13 起全仓 `.gitignore` 挡 `*.exe`,交付物只走 Gitea
|
||||||
|
Release 附件,本地保留);**zip 永不入库**(.gitignore 已挡);
|
||||||
- publish_gitea.py 幂等:release 已存在则复用,同名资产默认跳过,`--replace` 删旧重传;
|
- publish_gitea.py 幂等:release 已存在则复用,同名资产默认跳过,`--replace` 删旧重传;
|
||||||
- token 读环境变量 `GITEA_TOKEN`:本机已用 setx 持久化为用户级变量(新开的会话/终端自动
|
- token 读环境变量 `GITEA_TOKEN`:本机已用 setx 持久化为用户级变量(新开的会话/终端自动
|
||||||
继承,值不入库;在 setx 之前启动的进程读不到,需重开终端),急用时也可临时传 `--token`;
|
继承,值不入库;在 setx 之前启动的进程读不到,需重开终端),急用时也可临时传 `--token`;
|
||||||
@@ -139,17 +165,104 @@ python publish_gitea.py DLPilot-v<版本>-win64.zip --replace --insecure # 上
|
|||||||
- GUI 与 hdmi_source.ps1 各自独立实现显示器枚举,行为可能微差(GUI 与 `-List` 序号一致,
|
- GUI 与 hdmi_source.ps1 各自独立实现显示器枚举,行为可能微差(GUI 与 `-List` 序号一致,
|
||||||
但与"设置"面板序号无保证);多屏操作前先确认序号;
|
但与"设置"面板序号无保证);多屏操作前先确认序号;
|
||||||
- `pattern_slots.json` 兼容两种历史格式,写出只写新格式(详见 §3.11);
|
- `pattern_slots.json` 兼容两种历史格式,写出只写新格式(详见 §3.11);
|
||||||
- CustomTkinter 6.x:`CTkComboBox` 不支持 `textvariable`(用 `.set()/.get()`);`CTkTextbox`
|
- `--seconds` 冒烟在已有 DLPilot 实例(含用户开着的 exe)运行时会"挂起":run_gui
|
||||||
的 `tag_config` 在部分版本不可用,app_gui 已 try/except 兜底为单色;同一容器内
|
检测到单实例互斥后弹模态框等点击。无头验证改用直启 `App(5)` 的等效脚本,或先关实例;
|
||||||
grid/pack 不可混用(卡片用 grid 挂进列,卡片内部一律 pack——`_card` 返回 (卡片, 内容));
|
重打包同理——build_exe.py 会 taskkill 同名进程,等于替用户关掉开着的实例;
|
||||||
|
- 旧版 `app_gui.py` 仅保留作历史对照,当前入口使用 `app_fluent.py`;新增或修改界面时
|
||||||
|
应遵循 PySide6-Fluent-Widgets 的控件和 Qt 布局,不要把旧版 CustomTkinter 依赖带回入口。
|
||||||
|
|
||||||
## 7. 验证清单(改完代码后)
|
## 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);
|
2. `python selftest_rs485.py` —— 光机协议回归(改 dlpc_rs485.py 后必跑,向量来自上游 protocols.md);
|
||||||
3. `python hdmi_gui.py --seconds 5` —— GUI 冒烟(自动开关);
|
3. `python selftest_theme.py` —— 主题切换回归(改 app_fluent.py 涉及样式/主题后必跑;
|
||||||
4. `python hdmi_gui.py --test-pattern Checker --area 800x600 --seconds 3` —— 投图链路;
|
离屏渲染,Dark/Light 往返后校验 swatch 底色样式与渲染像素、标题字号);
|
||||||
5. `python hdmi_gui.py --dump` —— EDID 解算不抛异常;
|
3b. `python selftest_log.py` —— 收发日志上限/队列消费回归(改 `_dlpc_log`/`_poll_dlpc`
|
||||||
6. `python hdmi_gui.py --dlpc-cmd M999`(不带 --port)—— 应明确报错而非崩溃(无硬件时的优雅降级);
|
后必跑;600 行封顶 500、坏条目不中断 drain);
|
||||||
7. `python build_exe.py` → 双击 release/DLPilot.exe 确认图标/双栏布局/投图/光机区渲染;
|
4. `python hdmi_gui.py --seconds 5` —— GUI 冒烟(自动开关);
|
||||||
8. `python package_release.py <版本>` —— 不报"引用图片缺失"即栏位完整性 OK。
|
5. `python hdmi_gui.py --test-pattern Checker --area 800x600 --seconds 3` —— 投图链路;
|
||||||
|
6. `python hdmi_gui.py --dump` —— EDID 解算不抛异常;
|
||||||
|
7. `python hdmi_gui.py --dlpc-cmd M999`(不带 --port)—— 应明确报错而非崩溃(无硬件时的优雅降级);
|
||||||
|
8. `python build_exe.py` → 双击 release/DLPilot.exe 确认图标/双栏布局/投图/光机区渲染;
|
||||||
|
9. `python package_release.py <版本>` —— 不报"引用图片缺失"即栏位完整性 OK。
|
||||||
|
|
||||||
|
## 8. v0.4.0 进度记录(2026-09-24)
|
||||||
|
|
||||||
|
- 入口已切换 `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)与设备
|
||||||
|
拔出混判,一次转换器停滞即断链误报"设备拔出"。
|
||||||
|
- 2026-10-14 新增面向二供的独立使用手册 `使用说明.md` + `使用说明.pdf`(基于
|
||||||
|
README 与 v0.4.3 Release 编写,从解压安装包写起的操作级文档,含命令参考/典型
|
||||||
|
流程/FAQ/版本沿革;不随发布 zip 分发,单独发给供应商)。PDF 由 md 生成:
|
||||||
|
python-markdown → Chrome headless 矢量打印 → pymupdf 回填目录页码/页脚/元数据,
|
||||||
|
两遍编译(第一遍定位各章节页码,第二遍回填目录),构建脚本为一次性临时产物
|
||||||
|
未入库;后续改 md 后需按同流程重出 PDF。文档拓扑表述已按用户勘误校准:
|
||||||
|
HDMI 与 RS485 接同一块转接板,GD32E230 是板上主控 MCU(逻辑控制),无独立
|
||||||
|
"GD32 网关"说法(README/根 README/本文其他处旧措辞待同步)。§3 界面总览含
|
||||||
|
标注版主界面截图(`manual-img/ui-overview-quickstart.png`,手册配图目录)与
|
||||||
|
四条快速开始要点;已记录"转接板上电默认开光机"新事实(§3/§7.1)。现 11 页。
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
> HDMI **源端**(EDID 查看、分辨率切换、纯色/测试图案投屏)+ **光机控制**
|
> HDMI **源端**(EDID 查看、分辨率切换、纯色/测试图案投屏)+ **光机控制**
|
||||||
> (RS485 直连板上 GD32 网关,开关光机/设光强/查温度电流)。
|
> (RS485 直连板上 GD32 网关,开关光机/设光强/查温度电流)。
|
||||||
>
|
>
|
||||||
> - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases)
|
> - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases)
|
||||||
> 下载 `DLPilot-vX.Y.Z-win64.zip`,解压双击 `DLPilot.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
|
> 下载 `DLPilot-vX.Y.Z-win64.zip`,解压双击 `DLPilot.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
|
||||||
> - **要改代码/重新打包**:见 §8、§9。
|
> - **要改代码/重新打包**:见 §8、§9。
|
||||||
>
|
>
|
||||||
> 更新:2026-09-11
|
> 更新:2026-09-24
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
| EDID 逐字节解析 | "EDID 原始数据"框右上"逐字节解析"按钮:按偏移逐字段展开基础块与 CTA-861 扩展块,每字段附原始字节 hex;命令行等价 `--edid-fields` |
|
| EDID 逐字节解析 | "EDID 原始数据"框右上"逐字节解析"按钮:按偏移逐字段展开基础块与 CTA-861 扩展块,每字段附原始字节 hex;命令行等价 `--edid-fields` |
|
||||||
| 分辨率切换 | "分辨率"下拉列出显卡按该屏 EDID 提供的全部模式;**应用**=临时切换(不写注册表,重启/恢复即还原),**恢复**=回到本次会话切改前的模式 |
|
| 分辨率切换 | "分辨率"下拉列出显卡按该屏 EDID 提供的全部模式;**应用**=临时切换(不写注册表,重启/恢复即还原),**恢复**=回到本次会话切改前的模式 |
|
||||||
| 测试图案投屏 | 全屏投到所选显示器,测试卡区域可选 `640x360 / 800x600 / full 铺满`;内置 纯红/纯绿/纯蓝/黑白棋盘格/网格线 + R/G/B 三行 55/AA/FF 纯色值按钮 + 3 个自定义图片栏位 |
|
| 测试图案投屏 | 全屏投到所选显示器,测试卡区域可选 `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) |
|
| 光机控制 | RS485 → 板载 GD32E230 网关 → DLPC3421:开关光机(可定时)、设光强、查电流/温度/版本、TEC 控制、软件重启;光强/定时提供快捷滑条;收发日志带协议译码(见 §5) |
|
||||||
| 智能显示拓扑 | 仅当检测到转接板(EDID 厂商 "XLS")接入但未进入桌面(Windows 卡在"仅电脑屏幕")时才强制扩展把它拉回;你主动选的"仅第二屏幕"等拓扑不会被改动 |
|
| 智能显示拓扑 | 仅当检测到转接板(EDID 厂商 "XLS")接入但未进入桌面(Windows 卡在"仅电脑屏幕")时才强制扩展把它拉回;你主动选的"仅第二屏幕"等拓扑不会被改动 |
|
||||||
| 命令行模式 | 全部功能可脚本化调用,供自动化测试(见 §6、§7) |
|
| 命令行模式 | 全部功能可脚本化调用,供自动化测试(见 §6、§7) |
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
## 2. 下载与安装
|
## 2. 下载与安装
|
||||||
|
|
||||||
1. 打开 [Releases 页面](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases),取最新的
|
1. 打开 [Releases 页面](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases),取最新的
|
||||||
`DLPilot-vX.Y.Z-win64.zip`;
|
`DLPilot-vX.Y.Z-win64.zip`;
|
||||||
2. 解压到**任意可写目录**(自定义图片与栏位配置会写到 exe 旁边);
|
2. 解压到**任意可写目录**(自定义图片与栏位配置会写到 exe 旁边);
|
||||||
3. 双击 `DLPilot.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。
|
3. 双击 `DLPilot.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。
|
||||||
@@ -52,10 +52,11 @@ DLPilot/
|
|||||||
```
|
```
|
||||||
左列(HDMI 源端) 右列(光机控制 RS485)
|
左列(HDMI 源端) 右列(光机控制 RS485)
|
||||||
┌ 显示器选择区域 [刷新] ┐ ┌ 串口选择 + 波特率/8-N-1 参数/连接 ┐
|
┌ 显示器选择区域 [刷新] ┐ ┌ 串口选择 + 波特率/8-N-1 参数/连接 ┐
|
||||||
├ 支持的模式: 分辨率[应用][恢复] + 明细 ┐ ├ 串口数据 — 收发日志(译码+hex) ┤
|
├ 支持的模式: 分辨率[应用][恢复] + 明细 ┐ ├ 电源检查 — PD自检/FPDO挡位/功率读数 ┤
|
||||||
├ EDID 信息(表格) ┘ ├ 光机命令 — 快捷按钮+TEC │
|
├ EDID 信息 ┘ ├ 串口数据 — 收发日志(译码+hex) ┤
|
||||||
├ 测试图案 / 纯色 / 自定义 / 测试卡区域 │ 预设下拉 / 自定义 / 温度读取 │
|
├ 测试图案 / 纯色 / 校准图案 / 测试卡区域 │ ├ 光机命令 — 快捷按钮+TEC │
|
||||||
├ EDID 原始数据 [逐字节解析] ┘ └ 快捷滑条 — 光强/定时/TEC温度 ┘
|
├ EDID 原始数据 [逐字节解析] ┘ │ 预设下拉 / 温度电流读取 / 自定义 │
|
||||||
|
└─────────────────────────────┘ └ 快捷滑条 — 光强/定时/TEC温度 ┘
|
||||||
```
|
```
|
||||||
|
|
||||||
1. 左上显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举;
|
1. 左上显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举;
|
||||||
@@ -99,6 +100,12 @@ GUI"光机控制"区:选串口 → 连接 → 点快捷按钮或命令下拉/
|
|||||||
多口描述相同时自动附物理位置消歧。日志区实时显示 `→ 命令` / `← 译码结果 (原始 hex)`,
|
多口描述相同时自动附物理位置消歧。日志区实时显示 `→ 命令` / `← 译码结果 (原始 hex)`,
|
||||||
错误自动附释义(如 `err:3(I2C NACK)`)。
|
错误自动附释义(如 `err:3(I2C NACK)`)。
|
||||||
|
|
||||||
|
"电源检查"卡:**电源自检**依序发 M301(协议)→M302(挡位最大电流)→M303(实测母线电压),
|
||||||
|
按实测电压就近判定 PD 固定挡(5/9/12/15/20/28V,容差 ±5%):**12V 挡且 ≥3A 绿底
|
||||||
|
"FPDO: 12V@3A"**;挡位不符(如 9V 挡)**红底**显示实际挡位;不落在任何标准挡显示
|
||||||
|
**ERR**(CH224Q 电压配置寄存器只写不可读,挡位只能靠实测判定)。**功率检测**依序发
|
||||||
|
M303+M304+M305,读数框显示 电压(V) / 电流(mA) / 功率(W,mW 换算两位小数)。
|
||||||
|
|
||||||
| 命令 | 功能 | 响应示例 |
|
| 命令 | 功能 | 响应示例 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `M730S0` / `M730S1` | 开 / 关光机 | `ok` |
|
| `M730S0` / `M730S1` | 开 / 关光机 | `ok` |
|
||||||
@@ -106,6 +113,11 @@ GUI"光机控制"区:选串口 → 连接 → 点快捷按钮或命令下拉/
|
|||||||
| `M731S100` | 设光强 54-800(RGB 同值,写后读回复核) | `ok` / `err:R..G..B..` |
|
| `M731S100` | 设光强 54-800(RGB 同值,写后读回复核) | `ok` / `err:R..G..B..` |
|
||||||
| `M732` | 查三路电流设置值(0-65535) | `okR110G110B110` |
|
| `M732` | 查三路电流设置值(0-65535) | `okR110G110B110` |
|
||||||
| `M201` / `M202` | 查 DMD / LAMP 温度(0.1°C) | `okTL:246` = 24.6°C |
|
| `M201` / `M202` | 查 DMD / LAMP 温度(0.1°C) | `okTL:246` = 24.6°C |
|
||||||
|
| `M301` | 查电源协议(CH224Q 握手结果) | ASCII:`PD` / `QC3.0` / `EPR` … |
|
||||||
|
| `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` |
|
| `M999` | 查固件版本 | `okv20.0.0.1` |
|
||||||
| `M737` | 查投光是否结束(投光中延迟应答) | `ok` |
|
| `M737` | 查投光是否结束(投光中延迟应答) | `ok` |
|
||||||
| `M7S0` / `M7S1` | TEC 自动控温开 / 关(注意 S0=开) | `ok` |
|
| `M7S0` / `M7S1` | TEC 自动控温开 / 关(注意 S0=开) | `ok` |
|
||||||
@@ -186,7 +198,8 @@ python la_wave_analyzer3.py <capture.csv> # 3 通道(RGB 三组同拍, ch8/
|
|||||||
DLPilot/
|
DLPilot/
|
||||||
├── src/ # 源代码(人工维护区)
|
├── src/ # 源代码(人工维护区)
|
||||||
│ ├── hdmi_gui.py # 入口与命令行模式(argparse 分发)
|
│ ├── hdmi_gui.py # 入口与命令行模式(argparse 分发)
|
||||||
│ ├── app_gui.py # CustomTkinter 主界面(双栏仪表盘)
|
│ ├── app_fluent.py # PySide6-Fluent-Widgets 主界面(双栏仪表盘)
|
||||||
|
│ ├── app_gui.py # 历史 CustomTkinter 界面(保留,不由入口加载)
|
||||||
│ ├── display_win.py # Win32 显示器枚举/分辨率/智能拓扑(纯标准库)
|
│ ├── display_win.py # Win32 显示器枚举/分辨率/智能拓扑(纯标准库)
|
||||||
│ ├── edid_parse.py # EDID 1.4/CEA-861 解析(纯标准库)
|
│ ├── edid_parse.py # EDID 1.4/CEA-861 解析(纯标准库)
|
||||||
│ ├── patterns.py # 测试图案全屏投屏窗口
|
│ ├── patterns.py # 测试图案全屏投屏窗口
|
||||||
@@ -205,17 +218,17 @@ DLPilot/
|
|||||||
│ ├── bw_*.png ×5 # 640x360 黑白系列
|
│ ├── bw_*.png ×5 # 640x360 黑白系列
|
||||||
│ └── solid_*.png ×10 # 800x600 纯色系列
|
│ └── solid_*.png ×10 # 800x600 纯色系列
|
||||||
├── release/ # 构建与发布产物(机器生成区, 不手改)
|
├── release/ # 构建与发布产物(机器生成区, 不手改)
|
||||||
│ ├── DLPilot.exe # 打包单文件版(改动跟随源码提交入库)
|
│ ├── DLPilot.exe # 打包单文件版(不入库,本地保留;随发布上传 Gitea Release)
|
||||||
│ ├── pattern_slots.json # exe 运行配置(栏位预置 + 串口记忆)
|
│ ├── pattern_slots.json # exe 运行配置(栏位预置 + 串口记忆)
|
||||||
│ ├── *.png / README.md # 附属文件平铺副本(由 package_release.py 从 assets/ 同步)
|
│ ├── *.png / README.md # 附属文件平铺副本(由 package_release.py 从 assets/ 同步)
|
||||||
│ └── *.zip # 发布包(不入库, 上传 Gitea Release)
|
│ └── *.zip # 发布包(不入库, 上传 Gitea Release)
|
||||||
├── README.md # 本说明
|
├── README.md # 本说明
|
||||||
├── AGENTS.md # 面向 AI/Agent 的工程档案(结构/约定/构建规程)
|
├── 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/命令行纯标准库;
|
从源码运行:`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。
|
自定义图片要 JPG/BMP/WebP 需可选 Pillow。
|
||||||
|
|
||||||
## 9. 构建与发布
|
## 9. 构建与发布
|
||||||
@@ -224,7 +237,7 @@ GUI 需 customtkinter(`py -m pip install customtkinter`);光机控制需 p
|
|||||||
cd src
|
cd src
|
||||||
python selftest_rs485.py # 光机协议回归(改 dlpc_rs485.py 后必跑)
|
python selftest_rs485.py # 光机协议回归(改 dlpc_rs485.py 后必跑)
|
||||||
python build_exe.py # 打包 → ../release/DLPilot.exe(自动装 PyInstaller;
|
python build_exe.py # 打包 → ../release/DLPilot.exe(自动装 PyInstaller;
|
||||||
# 内置 pyserial/customtkinter, CTk 主题数据自动收集)
|
# 内置 pyserial/PySide6-Fluent-Widgets, Fluent 主题资源自动收集)
|
||||||
python build_exe.py --debug # 额外出一个带控制台的调试变体(--dump 可见输出)
|
python build_exe.py --debug # 额外出一个带控制台的调试变体(--dump 可见输出)
|
||||||
python package_release.py 0.2.0 # 组装 release/DLPilot-v0.2.0-win64.zip
|
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)
|
python publish_gitea.py DLPilot-v0.2.0-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN)
|
||||||
|
|||||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 889 B |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 889 B |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -4,11 +4,11 @@
|
|||||||
> HDMI **源端**(EDID 查看、分辨率切换、纯色/测试图案投屏)+ **光机控制**
|
> HDMI **源端**(EDID 查看、分辨率切换、纯色/测试图案投屏)+ **光机控制**
|
||||||
> (RS485 直连板上 GD32 网关,开关光机/设光强/查温度电流)。
|
> (RS485 直连板上 GD32 网关,开关光机/设光强/查温度电流)。
|
||||||
>
|
>
|
||||||
> - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases)
|
> - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases)
|
||||||
> 下载 `DLPilot-vX.Y.Z-win64.zip`,解压双击 `DLPilot.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
|
> 下载 `DLPilot-vX.Y.Z-win64.zip`,解压双击 `DLPilot.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
|
||||||
> - **要改代码/重新打包**:见 §8、§9。
|
> - **要改代码/重新打包**:见 §8、§9。
|
||||||
>
|
>
|
||||||
> 更新:2026-09-11
|
> 更新:2026-09-24
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
| EDID 逐字节解析 | "EDID 原始数据"框右上"逐字节解析"按钮:按偏移逐字段展开基础块与 CTA-861 扩展块,每字段附原始字节 hex;命令行等价 `--edid-fields` |
|
| EDID 逐字节解析 | "EDID 原始数据"框右上"逐字节解析"按钮:按偏移逐字段展开基础块与 CTA-861 扩展块,每字段附原始字节 hex;命令行等价 `--edid-fields` |
|
||||||
| 分辨率切换 | "分辨率"下拉列出显卡按该屏 EDID 提供的全部模式;**应用**=临时切换(不写注册表,重启/恢复即还原),**恢复**=回到本次会话切改前的模式 |
|
| 分辨率切换 | "分辨率"下拉列出显卡按该屏 EDID 提供的全部模式;**应用**=临时切换(不写注册表,重启/恢复即还原),**恢复**=回到本次会话切改前的模式 |
|
||||||
| 测试图案投屏 | 全屏投到所选显示器,测试卡区域可选 `640x360 / 800x600 / full 铺满`;内置 纯红/纯绿/纯蓝/黑白棋盘格/网格线 + R/G/B 三行 55/AA/FF 纯色值按钮 + 3 个自定义图片栏位 |
|
| 测试图案投屏 | 全屏投到所选显示器,测试卡区域可选 `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) |
|
| 光机控制 | RS485 → 板载 GD32E230 网关 → DLPC3421:开关光机(可定时)、设光强、查电流/温度/版本、TEC 控制、软件重启;光强/定时提供快捷滑条;收发日志带协议译码(见 §5) |
|
||||||
| 智能显示拓扑 | 仅当检测到转接板(EDID 厂商 "XLS")接入但未进入桌面(Windows 卡在"仅电脑屏幕")时才强制扩展把它拉回;你主动选的"仅第二屏幕"等拓扑不会被改动 |
|
| 智能显示拓扑 | 仅当检测到转接板(EDID 厂商 "XLS")接入但未进入桌面(Windows 卡在"仅电脑屏幕")时才强制扩展把它拉回;你主动选的"仅第二屏幕"等拓扑不会被改动 |
|
||||||
| 命令行模式 | 全部功能可脚本化调用,供自动化测试(见 §6、§7) |
|
| 命令行模式 | 全部功能可脚本化调用,供自动化测试(见 §6、§7) |
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
## 2. 下载与安装
|
## 2. 下载与安装
|
||||||
|
|
||||||
1. 打开 [Releases 页面](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases),取最新的
|
1. 打开 [Releases 页面](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases),取最新的
|
||||||
`DLPilot-vX.Y.Z-win64.zip`;
|
`DLPilot-vX.Y.Z-win64.zip`;
|
||||||
2. 解压到**任意可写目录**(自定义图片与栏位配置会写到 exe 旁边);
|
2. 解压到**任意可写目录**(自定义图片与栏位配置会写到 exe 旁边);
|
||||||
3. 双击 `DLPilot.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。
|
3. 双击 `DLPilot.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。
|
||||||
@@ -52,10 +52,11 @@ DLPilot/
|
|||||||
```
|
```
|
||||||
左列(HDMI 源端) 右列(光机控制 RS485)
|
左列(HDMI 源端) 右列(光机控制 RS485)
|
||||||
┌ 显示器选择区域 [刷新] ┐ ┌ 串口选择 + 波特率/8-N-1 参数/连接 ┐
|
┌ 显示器选择区域 [刷新] ┐ ┌ 串口选择 + 波特率/8-N-1 参数/连接 ┐
|
||||||
├ 支持的模式: 分辨率[应用][恢复] + 明细 ┐ ├ 串口数据 — 收发日志(译码+hex) ┤
|
├ 支持的模式: 分辨率[应用][恢复] + 明细 ┐ ├ 电源检查 — PD自检/FPDO挡位/功率读数 ┤
|
||||||
├ EDID 信息(表格) ┘ ├ 光机命令 — 快捷按钮+TEC │
|
├ EDID 信息 ┘ ├ 串口数据 — 收发日志(译码+hex) ┤
|
||||||
├ 测试图案 / 纯色 / 自定义 / 测试卡区域 │ 预设下拉 / 自定义 / 温度读取 │
|
├ 测试图案 / 纯色 / 校准图案 / 测试卡区域 │ ├ 光机命令 — 快捷按钮+TEC │
|
||||||
├ EDID 原始数据 [逐字节解析] ┘ └ 快捷滑条 — 光强/定时/TEC温度 ┘
|
├ EDID 原始数据 [逐字节解析] ┘ │ 预设下拉 / 温度电流读取 / 自定义 │
|
||||||
|
└─────────────────────────────┘ └ 快捷滑条 — 光强/定时/TEC温度 ┘
|
||||||
```
|
```
|
||||||
|
|
||||||
1. 左上显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举;
|
1. 左上显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举;
|
||||||
@@ -99,6 +100,12 @@ GUI"光机控制"区:选串口 → 连接 → 点快捷按钮或命令下拉/
|
|||||||
多口描述相同时自动附物理位置消歧。日志区实时显示 `→ 命令` / `← 译码结果 (原始 hex)`,
|
多口描述相同时自动附物理位置消歧。日志区实时显示 `→ 命令` / `← 译码结果 (原始 hex)`,
|
||||||
错误自动附释义(如 `err:3(I2C NACK)`)。
|
错误自动附释义(如 `err:3(I2C NACK)`)。
|
||||||
|
|
||||||
|
"电源检查"卡:**电源自检**依序发 M301(协议)→M302(挡位最大电流)→M303(实测母线电压),
|
||||||
|
按实测电压就近判定 PD 固定挡(5/9/12/15/20/28V,容差 ±5%):**12V 挡且 ≥3A 绿底
|
||||||
|
"FPDO: 12V@3A"**;挡位不符(如 9V 挡)**红底**显示实际挡位;不落在任何标准挡显示
|
||||||
|
**ERR**(CH224Q 电压配置寄存器只写不可读,挡位只能靠实测判定)。**功率检测**依序发
|
||||||
|
M303+M304+M305,读数框显示 电压(V) / 电流(mA) / 功率(W,mW 换算两位小数)。
|
||||||
|
|
||||||
| 命令 | 功能 | 响应示例 |
|
| 命令 | 功能 | 响应示例 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `M730S0` / `M730S1` | 开 / 关光机 | `ok` |
|
| `M730S0` / `M730S1` | 开 / 关光机 | `ok` |
|
||||||
@@ -106,6 +113,11 @@ GUI"光机控制"区:选串口 → 连接 → 点快捷按钮或命令下拉/
|
|||||||
| `M731S100` | 设光强 54-800(RGB 同值,写后读回复核) | `ok` / `err:R..G..B..` |
|
| `M731S100` | 设光强 54-800(RGB 同值,写后读回复核) | `ok` / `err:R..G..B..` |
|
||||||
| `M732` | 查三路电流设置值(0-65535) | `okR110G110B110` |
|
| `M732` | 查三路电流设置值(0-65535) | `okR110G110B110` |
|
||||||
| `M201` / `M202` | 查 DMD / LAMP 温度(0.1°C) | `okTL:246` = 24.6°C |
|
| `M201` / `M202` | 查 DMD / LAMP 温度(0.1°C) | `okTL:246` = 24.6°C |
|
||||||
|
| `M301` | 查电源协议(CH224Q 握手结果) | ASCII:`PD` / `QC3.0` / `EPR` … |
|
||||||
|
| `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` |
|
| `M999` | 查固件版本 | `okv20.0.0.1` |
|
||||||
| `M737` | 查投光是否结束(投光中延迟应答) | `ok` |
|
| `M737` | 查投光是否结束(投光中延迟应答) | `ok` |
|
||||||
| `M7S0` / `M7S1` | TEC 自动控温开 / 关(注意 S0=开) | `ok` |
|
| `M7S0` / `M7S1` | TEC 自动控温开 / 关(注意 S0=开) | `ok` |
|
||||||
@@ -186,7 +198,8 @@ python la_wave_analyzer3.py <capture.csv> # 3 通道(RGB 三组同拍, ch8/
|
|||||||
DLPilot/
|
DLPilot/
|
||||||
├── src/ # 源代码(人工维护区)
|
├── src/ # 源代码(人工维护区)
|
||||||
│ ├── hdmi_gui.py # 入口与命令行模式(argparse 分发)
|
│ ├── hdmi_gui.py # 入口与命令行模式(argparse 分发)
|
||||||
│ ├── app_gui.py # CustomTkinter 主界面(双栏仪表盘)
|
│ ├── app_fluent.py # PySide6-Fluent-Widgets 主界面(双栏仪表盘)
|
||||||
|
│ ├── app_gui.py # 历史 CustomTkinter 界面(保留,不由入口加载)
|
||||||
│ ├── display_win.py # Win32 显示器枚举/分辨率/智能拓扑(纯标准库)
|
│ ├── display_win.py # Win32 显示器枚举/分辨率/智能拓扑(纯标准库)
|
||||||
│ ├── edid_parse.py # EDID 1.4/CEA-861 解析(纯标准库)
|
│ ├── edid_parse.py # EDID 1.4/CEA-861 解析(纯标准库)
|
||||||
│ ├── patterns.py # 测试图案全屏投屏窗口
|
│ ├── patterns.py # 测试图案全屏投屏窗口
|
||||||
@@ -211,11 +224,11 @@ DLPilot/
|
|||||||
│ └── *.zip # 发布包(不入库, 上传 Gitea Release)
|
│ └── *.zip # 发布包(不入库, 上传 Gitea Release)
|
||||||
├── README.md # 本说明
|
├── README.md # 本说明
|
||||||
├── AGENTS.md # 面向 AI/Agent 的工程档案(结构/约定/构建规程)
|
├── 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/命令行纯标准库;
|
从源码运行:`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。
|
自定义图片要 JPG/BMP/WebP 需可选 Pillow。
|
||||||
|
|
||||||
## 9. 构建与发布
|
## 9. 构建与发布
|
||||||
@@ -224,7 +237,7 @@ GUI 需 customtkinter(`py -m pip install customtkinter`);光机控制需 p
|
|||||||
cd src
|
cd src
|
||||||
python selftest_rs485.py # 光机协议回归(改 dlpc_rs485.py 后必跑)
|
python selftest_rs485.py # 光机协议回归(改 dlpc_rs485.py 后必跑)
|
||||||
python build_exe.py # 打包 → ../release/DLPilot.exe(自动装 PyInstaller;
|
python build_exe.py # 打包 → ../release/DLPilot.exe(自动装 PyInstaller;
|
||||||
# 内置 pyserial/customtkinter, CTk 主题数据自动收集)
|
# 内置 pyserial/PySide6-Fluent-Widgets, Fluent 主题资源自动收集)
|
||||||
python build_exe.py --debug # 额外出一个带控制台的调试变体(--dump 可见输出)
|
python build_exe.py --debug # 额外出一个带控制台的调试变体(--dump 可见输出)
|
||||||
python package_release.py 0.2.0 # 组装 release/DLPilot-v0.2.0-win64.zip
|
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)
|
python publish_gitea.py DLPilot-v0.2.0-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN)
|
||||||
|
|||||||
@@ -6,12 +6,47 @@
|
|||||||
"file": "bw_star.png"
|
"file": "bw_star.png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "BW_ZONE",
|
"name": "black",
|
||||||
"file": "bw_zoneplate.png"
|
"file": "CeramixNano-black.png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "BW_TEST",
|
"name": "BW_TEST",
|
||||||
"file": "bw_testcard.png"
|
"file": "bw_testcard.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "calipoint",
|
||||||
|
"file": "CeramixNano-caliPoint.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "自定义5",
|
||||||
|
"file": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "circle_640P",
|
||||||
|
"file": "CeramixNano-circle_640p.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "自定义7",
|
||||||
|
"file": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "contrast",
|
||||||
|
"file": "CeramixNano-contrast.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "white",
|
||||||
|
"file": "CeramixNano-White.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sharpness",
|
||||||
|
"file": "CeramixNano-Sharpness_XY_3.png"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"serial": {
|
||||||
|
"port": "COM15",
|
||||||
|
"baud": 115200,
|
||||||
|
"bytesize": 8,
|
||||||
|
"parity": "N",
|
||||||
|
"stopbits": 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
@@ -33,9 +33,15 @@ MONO = ('Consolas', 12)
|
|||||||
BOLD = ('Microsoft YaHei UI', 13, 'bold')
|
BOLD = ('Microsoft YaHei UI', 13, 'bold')
|
||||||
TITLE_FONT = ('Microsoft YaHei UI', 18, 'bold')
|
TITLE_FONT = ('Microsoft YaHei UI', 18, 'bold')
|
||||||
|
|
||||||
|
# 电源自检状态位配色: 灰=未检测, 绿=正常回包, 红=错误/超时
|
||||||
|
CHIP_IDLE = ('gray78', 'gray34')
|
||||||
|
CHIP_IDLE_TEXT = ('#333333', '#E8E8E8')
|
||||||
|
CHIP_OK = '#2FA574'
|
||||||
|
CHIP_ERR = '#D9534F'
|
||||||
|
|
||||||
APP_NAME = 'DLPilot'
|
APP_NAME = 'DLPilot'
|
||||||
APP_VERSION = '0.3.2'
|
APP_VERSION = '0.3.6'
|
||||||
REPO_URL = 'https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools'
|
REPO_URL = 'https://gitea.hulk.wang/hulk/HW-Debug-Tools'
|
||||||
|
|
||||||
|
|
||||||
def get_icon_path():
|
def get_icon_path():
|
||||||
@@ -57,10 +63,30 @@ def _apply_dropdown_theme():
|
|||||||
ctk.ThemeManager.theme['DropdownMenu']['text_color'] = ('#1A1A1A', '#DADADA')
|
ctk.ThemeManager.theme['DropdownMenu']['text_color'] = ('#1A1A1A', '#DADADA')
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_single_instance():
|
||||||
|
"""单实例锁(Windows 命名互斥体): 已有实例在运行时返回 False。
|
||||||
|
|
||||||
|
双击 exe 开多个实例时, 只有先启动的能连串口, 后续实例连接报"拒绝访问",
|
||||||
|
且用户往往只看到后开的窗口 —— 表现为"连不上/断不开"。互斥体句柄随
|
||||||
|
进程存活, 进程退出自动释放。"""
|
||||||
|
ERROR_ALREADY_EXISTS = 183
|
||||||
|
ctypes.windll.kernel32.CreateMutexW(None, False, 'hulk.DLPilot.single')
|
||||||
|
return ctypes.windll.kernel32.GetLastError() != ERROR_ALREADY_EXISTS
|
||||||
|
|
||||||
|
|
||||||
def run_gui(auto_close=0):
|
def run_gui(auto_close=0):
|
||||||
"""入口: 配置外观并启动主窗口"""
|
"""入口: 配置外观并启动主窗口"""
|
||||||
ctk.set_appearance_mode('System')
|
ctk.set_appearance_mode('System')
|
||||||
_apply_dropdown_theme()
|
_apply_dropdown_theme()
|
||||||
|
if not _ensure_single_instance():
|
||||||
|
from tkinter import messagebox
|
||||||
|
import tkinter as tk
|
||||||
|
r = tk.Tk()
|
||||||
|
r.withdraw()
|
||||||
|
messagebox.showinfo('DLPilot 已在运行',
|
||||||
|
'检测到另一个 DLPilot 实例正在运行。\n'
|
||||||
|
'请使用已打开的窗口(串口同时只能被一个实例占用)。')
|
||||||
|
return
|
||||||
App(auto_close).mainloop()
|
App(auto_close).mainloop()
|
||||||
|
|
||||||
|
|
||||||
@@ -73,8 +99,8 @@ class App(ctk.CTk):
|
|||||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID('hulk.DLPilot')
|
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID('hulk.DLPilot')
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self.geometry('1440x920')
|
self.geometry('1440x1010') # 右列新增电源检查卡, 窗口加高
|
||||||
self.minsize(1180, 800)
|
self.minsize(1180, 860)
|
||||||
ic = get_icon_path()
|
ic = get_icon_path()
|
||||||
if ic:
|
if ic:
|
||||||
try:
|
try:
|
||||||
@@ -89,10 +115,17 @@ class App(ctk.CTk):
|
|||||||
self._orig_modes = {} # device -> 切改前的原始 DEVMODEW
|
self._orig_modes = {} # device -> 切改前的原始 DEVMODEW
|
||||||
# 光机控制链路: 回调发生在串口线程, 经队列投递回 GUI 主线程轮询
|
# 光机控制链路: 回调发生在串口线程, 经队列投递回 GUI 主线程轮询
|
||||||
self._dlpc_log_q = queue.Queue()
|
self._dlpc_log_q = queue.Queue()
|
||||||
self.dlpc = dlpc_rs485.DLPC485(self._dlpc_on_event, self._dlpc_on_status)
|
self.dlpc = dlpc_rs485.DLPC485(self._dlpc_on_event, self._dlpc_on_status,
|
||||||
|
on_frame=self._dlpc_on_frame)
|
||||||
self._dlpc_connected = False
|
self._dlpc_connected = False
|
||||||
self._poll_job = None
|
self._poll_job = None
|
||||||
self._tl_order = [] # 温度查询应答顺序: 'DMD'/'LAMP' 先到先得
|
self._tl_order = [] # 温度查询应答顺序: 'DMD'/'LAMP' 先到先得
|
||||||
|
self._pd_seq = [] # 电源自检待执行步骤: [(命令, kind), ...]
|
||||||
|
self._pd_values = {} # 自检采集值: {'ma': 挡位电流, 'mv': 实测电压}
|
||||||
|
self._pd_timer = None # 自检单步超时定时器
|
||||||
|
self._pwr_seq = [] # 功率检测待执行步骤
|
||||||
|
self._pwr_values = {} # 功率检测采集值: {'mv', 'ma'}
|
||||||
|
self._pwr_timer = None
|
||||||
self._build()
|
self._build()
|
||||||
self._load_displays()
|
self._load_displays()
|
||||||
# 主窗口按 ESC 也能关闭所有投屏(投屏窗口失焦时的兜底出口)
|
# 主窗口按 ESC 也能关闭所有投屏(投屏窗口失焦时的兜底出口)
|
||||||
@@ -205,10 +238,13 @@ class App(ctk.CTk):
|
|||||||
self.txt_modes.configure(state='disabled')
|
self.txt_modes.configure(state='disabled')
|
||||||
|
|
||||||
# -- EDID 信息 --
|
# -- EDID 信息 --
|
||||||
|
# CTkTextbox 而非 CTkScrollableFrame+逐行 Label: 后者 40+ 个画布控件,
|
||||||
|
# 窗口最大化时逐个重绘是卡顿主因; 文本框整体重排, 开销低一个量级
|
||||||
card, c = self._card(col, 'EDID 信息')
|
card, c = self._card(col, 'EDID 信息')
|
||||||
card.grid(row=2, column=0, sticky='nsew', pady=(10, 0))
|
card.grid(row=2, column=0, sticky='nsew', pady=(10, 0))
|
||||||
self.edid_scroll = ctk.CTkScrollableFrame(c, height=150)
|
self.txt_edid = ctk.CTkTextbox(c, font=MONO, wrap='none')
|
||||||
self.edid_scroll.pack(fill='both', expand=True)
|
self.txt_edid.pack(fill='both', expand=True)
|
||||||
|
self.txt_edid.configure(state='disabled')
|
||||||
|
|
||||||
# -- 测试图案 --
|
# -- 测试图案 --
|
||||||
card, c = self._card(col,'测试图案 — 黑底 1:1 居中, 投屏按 ESC 或"停止测试图像"退出',
|
card, c = self._card(col,'测试图案 — 黑底 1:1 居中, 投屏按 ESC 或"停止测试图像"退出',
|
||||||
@@ -319,6 +355,39 @@ class App(ctk.CTk):
|
|||||||
self._dlpc_log('帧格式: D5 01 LEN <命令> CRC / B5 TYPE LEN <payload> CRC, '
|
self._dlpc_log('帧格式: D5 01 LEN <命令> CRC / B5 TYPE LEN <payload> CRC, '
|
||||||
'累加和校验; 全部命令见 README"光机控制"')
|
'累加和校验; 全部命令见 README"光机控制"')
|
||||||
|
|
||||||
|
# -- 电源检查 --
|
||||||
|
# 网格对齐两行: col2 为读数框列(weight=1) -> FPDO 色框与功率黑框
|
||||||
|
# 起止位置完全一致(等长), 且随窗口拉伸自适应
|
||||||
|
card, c = self._card(col, '电源检查 — PD 自检 / 功率读数')
|
||||||
|
card.grid(row=2, column=0, sticky='ew', pady=(10, 0))
|
||||||
|
pwr_grid = ctk.CTkFrame(c, fg_color='transparent')
|
||||||
|
pwr_grid.pack(fill='x')
|
||||||
|
pwr_grid.grid_columnconfigure(2, weight=1)
|
||||||
|
ctk.CTkButton(pwr_grid, text='电源自检', width=90,
|
||||||
|
command=self._pd_selftest).grid(row=0, column=0, sticky='w')
|
||||||
|
proto_cell = ctk.CTkFrame(pwr_grid, fg_color='transparent')
|
||||||
|
proto_cell.grid(row=0, column=1, sticky='w', padx=(10, 0))
|
||||||
|
ctk.CTkLabel(proto_cell, text='协议:').pack(side='left', padx=(0, 4))
|
||||||
|
self.chip_pd = ctk.CTkLabel(proto_cell, text='PD', width=60, height=26,
|
||||||
|
corner_radius=6, fg_color=CHIP_IDLE,
|
||||||
|
text_color=CHIP_IDLE_TEXT)
|
||||||
|
self.chip_pd.pack(side='left')
|
||||||
|
self.var_fpdo = tk.StringVar(value='FPDO: —')
|
||||||
|
self.chip_fpdo = ctk.CTkLabel(pwr_grid, textvariable=self.var_fpdo,
|
||||||
|
height=26, corner_radius=6,
|
||||||
|
fg_color=CHIP_IDLE,
|
||||||
|
text_color=CHIP_IDLE_TEXT, anchor='w', padx=8)
|
||||||
|
self.chip_fpdo.grid(row=0, column=2, sticky='ew', padx=(12, 0))
|
||||||
|
ctk.CTkButton(pwr_grid, text='功率检测', width=90,
|
||||||
|
command=self._pwr_query).grid(row=1, column=0,
|
||||||
|
sticky='w', pady=(6, 0))
|
||||||
|
self.var_pwr = tk.StringVar(value='电压: — 电流: — 功率: —')
|
||||||
|
self.box_pwr = ctk.CTkLabel(pwr_grid, textvariable=self.var_pwr,
|
||||||
|
height=26, corner_radius=6,
|
||||||
|
fg_color=('#F2F2F2', '#181818'),
|
||||||
|
anchor='w', padx=8)
|
||||||
|
self.box_pwr.grid(row=1, column=2, sticky='ew', padx=(8, 0), pady=(6, 0))
|
||||||
|
|
||||||
# -- 光机命令 --
|
# -- 光机命令 --
|
||||||
# 标题行右侧: 电流实时读数 + 查电流按钮(okR..G..B.. 应答解析)
|
# 标题行右侧: 电流实时读数 + 查电流按钮(okR..G..B.. 应答解析)
|
||||||
cur_frame = ctk.CTkFrame(self, fg_color='transparent')
|
cur_frame = ctk.CTkFrame(self, fg_color='transparent')
|
||||||
@@ -329,7 +398,7 @@ class App(ctk.CTk):
|
|||||||
command=self._query_current).pack(side='left', padx=(6, 0))
|
command=self._query_current).pack(side='left', padx=(6, 0))
|
||||||
card, c = self._card(col,'光机命令 — 预设 / 自定义 / TEC',
|
card, c = self._card(col,'光机命令 — 预设 / 自定义 / TEC',
|
||||||
right_widget=cur_frame)
|
right_widget=cur_frame)
|
||||||
card.grid(row=2, column=0, sticky='ew', pady=(10, 0))
|
card.grid(row=3, column=0, sticky='ew', pady=(10, 0))
|
||||||
row1 = ctk.CTkFrame(c, fg_color='transparent')
|
row1 = ctk.CTkFrame(c, fg_color='transparent')
|
||||||
row1.pack(fill='x')
|
row1.pack(fill='x')
|
||||||
for name, cmd in (('开光机', 'M730S0'), ('关光机', 'M730S1'),
|
for name, cmd in (('开光机', 'M730S0'), ('关光机', 'M730S1'),
|
||||||
@@ -386,7 +455,7 @@ class App(ctk.CTk):
|
|||||||
|
|
||||||
# -- 快捷滑条 --
|
# -- 快捷滑条 --
|
||||||
card, c = self._card(col,'快捷滑条 — 光强 / 定时投光 / TEC 温度')
|
card, c = self._card(col,'快捷滑条 — 光强 / 定时投光 / TEC 温度')
|
||||||
card.grid(row=3, column=0, sticky='ew', pady=(10, 0))
|
card.grid(row=4, column=0, sticky='ew', pady=(10, 0))
|
||||||
rowa = ctk.CTkFrame(c, fg_color='transparent')
|
rowa = ctk.CTkFrame(c, fg_color='transparent')
|
||||||
rowa.pack(fill='x')
|
rowa.pack(fill='x')
|
||||||
ctk.CTkLabel(rowa, text='光强(54-800):').pack(side='left')
|
ctk.CTkLabel(rowa, text='光强(54-800):').pack(side='left')
|
||||||
@@ -503,16 +572,13 @@ class App(ctk.CTk):
|
|||||||
self._set_text(self.txt_hex, edid_parse.hex_dump(d['edid']))
|
self._set_text(self.txt_hex, edid_parse.hex_dump(d['edid']))
|
||||||
|
|
||||||
def _clear_edid_rows(self):
|
def _clear_edid_rows(self):
|
||||||
for w in self.edid_scroll.winfo_children():
|
self._set_text(self.txt_edid, '')
|
||||||
w.destroy()
|
|
||||||
|
|
||||||
def _add_edid_row(self, k, v):
|
def _add_edid_row(self, k, v):
|
||||||
row = ctk.CTkFrame(self.edid_scroll, fg_color='transparent')
|
self.txt_edid.configure(state='normal')
|
||||||
row.pack(fill='x', pady=1)
|
self.txt_edid.insert('end', f'{k:<16}{v}\n')
|
||||||
ctk.CTkLabel(row, text=k, width=132, anchor='w',
|
self.txt_edid.see('end')
|
||||||
text_color='gray60').pack(side='left')
|
self.txt_edid.configure(state='disabled')
|
||||||
ctk.CTkLabel(row, text=v, anchor='w').pack(side='left',
|
|
||||||
fill='x', expand=True)
|
|
||||||
|
|
||||||
def _apply_mode(self):
|
def _apply_mode(self):
|
||||||
d = self._cur_display()
|
d = self._cur_display()
|
||||||
@@ -680,6 +746,13 @@ class App(ctk.CTk):
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# 光机控制(RS485)
|
# 光机控制(RS485)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
def _dlpc_on_frame(self, fr):
|
||||||
|
"""结构化应答帧(串口线程): 电源自检/功率检测序列进行中时路由给状态机"""
|
||||||
|
if self._pd_seq:
|
||||||
|
self._dlpc_log_q.put(('pdframe', fr))
|
||||||
|
elif self._pwr_seq:
|
||||||
|
self._dlpc_log_q.put(('pwrframe', fr))
|
||||||
|
|
||||||
def _dlpc_on_event(self, line):
|
def _dlpc_on_event(self, line):
|
||||||
# 温度查询应答: okTL:<0.1°C>, 依发送顺序归入 DMD/LAMP 实时显示框
|
# 温度查询应答: okTL:<0.1°C>, 依发送顺序归入 DMD/LAMP 实时显示框
|
||||||
m = re.search(r'← okTL:(-?\d+)', line)
|
m = re.search(r'← okTL:(-?\d+)', line)
|
||||||
@@ -725,6 +798,8 @@ class App(ctk.CTk):
|
|||||||
else:
|
else:
|
||||||
color = 'gray60'
|
color = 'gray60'
|
||||||
self.lbl_dlpc_st.configure(text=text, text_color=color)
|
self.lbl_dlpc_st.configure(text=text, text_color=color)
|
||||||
|
if text.startswith(('已断开', '串口丢失')):
|
||||||
|
self._serial_reset_state()
|
||||||
elif kind == 'temp':
|
elif kind == 'temp':
|
||||||
if text.startswith('DMD'):
|
if text.startswith('DMD'):
|
||||||
self.var_dmd.set(text)
|
self.var_dmd.set(text)
|
||||||
@@ -732,7 +807,12 @@ class App(ctk.CTk):
|
|||||||
self.var_lamp.set(text)
|
self.var_lamp.set(text)
|
||||||
elif kind == 'current':
|
elif kind == 'current':
|
||||||
self.var_current.set(text)
|
self.var_current.set(text)
|
||||||
self._dlpc_log(text)
|
elif kind == 'pdframe':
|
||||||
|
self._pd_step_done(text.type_code == 0xF0, text)
|
||||||
|
elif kind == 'pwrframe':
|
||||||
|
self._pwr_step_done(text)
|
||||||
|
else:
|
||||||
|
self._dlpc_log(text)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
pass
|
||||||
connected = self.dlpc.is_connected()
|
connected = self.dlpc.is_connected()
|
||||||
@@ -778,6 +858,7 @@ class App(ctk.CTk):
|
|||||||
def _dlpc_toggle(self):
|
def _dlpc_toggle(self):
|
||||||
if self.dlpc.is_connected():
|
if self.dlpc.is_connected():
|
||||||
self.dlpc.close()
|
self.dlpc.close()
|
||||||
|
self._serial_reset_state() # 断开时清序列/定时器/应答归位, 防旧账错配
|
||||||
return
|
return
|
||||||
port = self._dlpc_selected_port()
|
port = self._dlpc_selected_port()
|
||||||
if not port:
|
if not port:
|
||||||
@@ -796,10 +877,28 @@ class App(ctk.CTk):
|
|||||||
config.save_slots(self.slots, self.area_var.get()) # 记住本次串口
|
config.save_slots(self.slots, self.area_var.get()) # 记住本次串口
|
||||||
self.dlpc.open(port, baud, bytesize=bytesize, parity=parity, stopbits=stopbits)
|
self.dlpc.open(port, baud, bytesize=bytesize, parity=parity, stopbits=stopbits)
|
||||||
|
|
||||||
|
def _serial_reset_state(self):
|
||||||
|
"""断开/失连时清理进行中的序列与应答归位队列(不碰串口线程本身)"""
|
||||||
|
self._pd_seq.clear()
|
||||||
|
self._pwr_seq.clear()
|
||||||
|
for t in (self._pd_timer, self._pwr_timer):
|
||||||
|
if t:
|
||||||
|
try:
|
||||||
|
self.after_cancel(t)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._pd_timer = self._pwr_timer = None
|
||||||
|
self._tl_order.clear()
|
||||||
|
|
||||||
def _dlpc_send(self, cmd):
|
def _dlpc_send(self, cmd):
|
||||||
if not self.dlpc.is_connected():
|
if not self.dlpc.is_connected():
|
||||||
self.lbl_dlpc_st.configure(text='未连接串口', text_color='#E07A3F')
|
self.lbl_dlpc_st.configure(text='未连接串口', text_color='#E07A3F')
|
||||||
return
|
return
|
||||||
|
if self._pd_seq or self._pwr_seq:
|
||||||
|
# 序列进行中: 手动命令的应答帧会被序列错认领(它不区分应答属于哪条
|
||||||
|
# 命令, 来一帧消费一步), 值会错位 —— 拒绝并提示, 序列结束再发
|
||||||
|
self._dlpc_log(f'序列进行中, 命令已忽略: {cmd}')
|
||||||
|
return
|
||||||
self.dlpc.send(cmd)
|
self.dlpc.send(cmd)
|
||||||
|
|
||||||
def _dlpc_send_preset(self):
|
def _dlpc_send_preset(self):
|
||||||
@@ -814,6 +913,141 @@ class App(ctk.CTk):
|
|||||||
if cmd:
|
if cmd:
|
||||||
self._dlpc_send(cmd)
|
self._dlpc_send(cmd)
|
||||||
|
|
||||||
|
# -- 电源检查: 自检序列 + 功率读数 --
|
||||||
|
|
||||||
|
def _pd_selftest(self):
|
||||||
|
"""电源自检: 依序 M301(协议) → M302(挡位最大电流) → M303(实测母线电压),
|
||||||
|
应答帧驱动序列前进(成功=TYPE 0xF0), 结束后按 _pd_fpdo_verdict 判定 FPDO"""
|
||||||
|
if not self.dlpc.is_connected():
|
||||||
|
self.lbl_dlpc_st.configure(text='未连接串口', text_color='#E07A3F')
|
||||||
|
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.configure(fg_color=CHIP_IDLE,
|
||||||
|
text_color=CHIP_IDLE_TEXT, text='PD')
|
||||||
|
self.chip_fpdo.configure(fg_color=CHIP_IDLE, text_color=CHIP_IDLE_TEXT)
|
||||||
|
self.var_fpdo.set('FPDO: —')
|
||||||
|
|
||||||
|
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 = self.after(3000, lambda: self._pd_step_done(False, None))
|
||||||
|
|
||||||
|
def _pd_step_done(self, ok, fr):
|
||||||
|
if not self._pd_seq:
|
||||||
|
return
|
||||||
|
if self._pd_timer:
|
||||||
|
self.after_cancel(self._pd_timer)
|
||||||
|
self._pd_timer = None
|
||||||
|
cmd, kind = self._pd_seq.pop(0)
|
||||||
|
raw = dlpc_rs485.payload_bytes(fr) if fr is not None else b''
|
||||||
|
if ok and kind == 'pd':
|
||||||
|
proto = raw.decode('ascii', 'replace').strip()
|
||||||
|
good = proto in ('PD', 'EPR')
|
||||||
|
self.chip_pd.configure(fg_color=CHIP_OK if good else CHIP_ERR,
|
||||||
|
text_color='#FFFFFF', text=proto or 'PD')
|
||||||
|
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()
|
||||||
|
if kind == 'pd':
|
||||||
|
self.chip_pd.configure(fg_color=CHIP_ERR, text_color='#FFFFFF',
|
||||||
|
text='ERR')
|
||||||
|
elif kind == 'fpdo_ma':
|
||||||
|
self.chip_fpdo.configure(fg_color=CHIP_ERR, text_color='#FFFFFF')
|
||||||
|
self.var_fpdo.set('FPDO: ERR')
|
||||||
|
self._dlpc_log('电源自检: 应答错误/超时, 序列中止')
|
||||||
|
|
||||||
|
def _pd_fpdo_verdict(self):
|
||||||
|
"""FPDO 判定: 电压挡位由 M303 实测母线电压就近判定(标准 PD 固定挡
|
||||||
|
5/9/12/15/20/28V, 容差 ±5%; CH224Q 0x0A 只写不可读, 挡位只能实测判定),
|
||||||
|
电流为 M302 挡位最大电流。默认挡 12V 且 ≥3A -> 绿底;
|
||||||
|
挡位不符(如 9V 挡) -> 红底; 实测电压不落在任何标准挡 -> ERR"""
|
||||||
|
mv = self._pd_values.get('mv')
|
||||||
|
ma = self._pd_values.get('ma')
|
||||||
|
if mv is None or ma is None:
|
||||||
|
self.chip_fpdo.configure(fg_color=CHIP_ERR, text_color='#FFFFFF')
|
||||||
|
self.var_fpdo.set('FPDO: ERR')
|
||||||
|
return
|
||||||
|
gear = next((g for g in (5, 9, 12, 15, 20, 28)
|
||||||
|
if abs(mv - g * 1000) <= g * 50), None) # ±5% = g*50 mV
|
||||||
|
a_txt = f'{ma / 1000:g}'
|
||||||
|
if gear is None:
|
||||||
|
self.chip_fpdo.configure(fg_color=CHIP_ERR, text_color='#FFFFFF')
|
||||||
|
self.var_fpdo.set('FPDO: ERR')
|
||||||
|
elif gear == 12 and ma >= 3000:
|
||||||
|
self.chip_fpdo.configure(fg_color=CHIP_OK, text_color='#FFFFFF')
|
||||||
|
self.var_fpdo.set(f'FPDO: 12V@{a_txt}A')
|
||||||
|
else:
|
||||||
|
self.chip_fpdo.configure(fg_color=CHIP_ERR, text_color='#FFFFFF')
|
||||||
|
self.var_fpdo.set(f'FPDO: {gear}V@{a_txt}A')
|
||||||
|
|
||||||
|
def _pwr_query(self):
|
||||||
|
"""功率检测: 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'), ('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 = self.after(3000, lambda: self._pwr_step_done(None))
|
||||||
|
|
||||||
|
def _pwr_step_done(self, fr):
|
||||||
|
if not self._pwr_seq:
|
||||||
|
return
|
||||||
|
if self._pwr_timer:
|
||||||
|
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) != want_len:
|
||||||
|
self._pwr_seq.clear()
|
||||||
|
self.var_pwr.set('电压: ERR 电流: — 功率: —')
|
||||||
|
self._dlpc_log('功率检测: 应答错误/超时, 中止')
|
||||||
|
return
|
||||||
|
if kind == 'mv':
|
||||||
|
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, 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):
|
def _light_entry_apply(self, _evt=None):
|
||||||
"""滑条数值输入框回车: 校验并限幅 54-800, 同步回滑条"""
|
"""滑条数值输入框回车: 校验并限幅 54-800, 同步回滑条"""
|
||||||
try:
|
try:
|
||||||
@@ -879,9 +1113,13 @@ class App(ctk.CTk):
|
|||||||
self._dlpc_send(f'M8S{v}')
|
self._dlpc_send(f'M8S{v}')
|
||||||
|
|
||||||
def destroy(self):
|
def destroy(self):
|
||||||
try: # 取消待触发的轮询回调, 避免销毁后 Tcl 报错
|
try: # 取消待触发的轮询/自检/功率超时回调, 避免销毁后 Tcl 报错
|
||||||
if self._poll_job:
|
if self._poll_job:
|
||||||
self.after_cancel(self._poll_job)
|
self.after_cancel(self._poll_job)
|
||||||
|
if self._pd_timer:
|
||||||
|
self.after_cancel(self._pd_timer)
|
||||||
|
if self._pwr_timer:
|
||||||
|
self.after_cancel(self._pwr_timer)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try: # 两种退出路径(关窗/--seconds 自动关)都收掉串口线程
|
try: # 两种退出路径(关窗/--seconds 自动关)都收掉串口线程
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
"""
|
"""
|
||||||
把 hdmi_gui.py 打包成单文件 exe(工具名: DLPilot)
|
把 hdmi_gui.py 打包成单文件 exe(工具名: DLPilot)
|
||||||
================================
|
================================
|
||||||
使用 PyInstaller --onefile --noconsole: Python 解释器 + 标准库 + tkinter
|
使用 PyInstaller --onefile --noconsole: Python 解释器、PySide6 和
|
||||||
全部打进一个 exe, 目标机器无需安装任何依赖。
|
PySide6-Fluent-Widgets 全部打进一个 exe, 目标机器无需安装依赖。
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
python build_exe.py 自动安装 PyInstaller(如缺)并打包
|
python build_exe.py 自动安装 PyInstaller(如缺)并打包
|
||||||
@@ -35,13 +35,22 @@ def ensure_pyinstaller():
|
|||||||
|
|
||||||
|
|
||||||
def extra_collect_args():
|
def extra_collect_args():
|
||||||
"""CustomTkinter 的主题/字体等数据文件必须显式收集, 否则打包后 GUI 启动报错"""
|
"""Fluent Widgets 的主题资源必须收集; PyInstaller 自带 Qt hooks 会处理 PySide6。"""
|
||||||
try:
|
try:
|
||||||
import customtkinter # noqa: F401
|
import qfluentwidgets # noqa: F401
|
||||||
return ['--collect-all', 'customtkinter']
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print('警告: 未安装 customtkinter, 打包产物将无法启动 GUI!')
|
print('警告: 未安装 PySide6-Fluent-Widgets, 请先执行 pip install PySide6-Fluent-Widgets')
|
||||||
return []
|
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):
|
def remove_stale(exe_path):
|
||||||
@@ -68,7 +77,7 @@ def build(name, extra_args):
|
|||||||
cmd = [sys.executable, '-m', 'PyInstaller',
|
cmd = [sys.executable, '-m', 'PyInstaller',
|
||||||
'--onefile', '--clean', '--name', name,
|
'--onefile', '--clean', '--name', name,
|
||||||
'--distpath', dist, '--workpath', work, '--specpath', work,
|
'--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):
|
if os.path.exists(ICO):
|
||||||
# exe 文件图标 + 运行时窗口图标资源(从 _MEIPASS 读取)
|
# exe 文件图标 + 运行时窗口图标资源(从 _MEIPASS 读取)
|
||||||
cmd += ['--icon', ICO, '--add-data', f'{ICO};.']
|
cmd += ['--icon', ICO, '--add-data', f'{ICO};.']
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ DLPC3421 光机 RS485 控制链路(板上 GD32E230 作 RS485→I2C 网关)
|
|||||||
响应语义/错误码/就绪门禁与实测帧示例):
|
响应语义/错误码/就绪门禁与实测帧示例):
|
||||||
物理层 RS485 半双工 115200 8N1(PC 经 USB-RS485 转换器接板, DE 由 MCU 硬件换向)
|
物理层 RS485 半双工 115200 8N1(PC 经 USB-RS485 转换器接板, DE 由 MCU 硬件换向)
|
||||||
命令帧 D5 01 LEN <ASCII命令> CRC 01=板类型, LEN=命令字节数
|
命令帧 D5 01 LEN <ASCII命令> CRC 01=板类型, LEN=命令字节数
|
||||||
响应帧 B5 TYPE LEN <payload> CRC payload 为 ASCII(ok / err:OL / okTL:246 ...)
|
响应帧 B5 TYPE LEN <payload> CRC payload 为 ASCII(ok / err:OL / okTL:246 ...),
|
||||||
|
例外: M303/M304/M305 载荷为大端二进制
|
||||||
|
(SQ52205 电源实测, 见 POWER_COMMANDS)
|
||||||
CRC 帧内第 2 字节(板类型)起累加到最后一个 payload 字节, 取低 8 位
|
CRC 帧内第 2 字节(板类型)起累加到最后一个 payload 字节, 取低 8 位
|
||||||
帧长 最小 6 字节, 最大 32 字节
|
帧长 最小 6 字节, 最大 32 字节
|
||||||
就绪门禁: DLPC3421 启动完成前 M730/M731/M732 被拒(回 0xFF), 属预期。
|
就绪门禁: DLPC3421 启动完成前 M730/M731/M732 被拒(回 0xFF), 属预期。
|
||||||
@@ -81,6 +83,53 @@ PRESETS = (
|
|||||||
Frame = collections.namedtuple('Frame', 'type_code payload raw')
|
Frame = collections.namedtuple('Frame', 'type_code payload raw')
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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', 'M305')
|
||||||
|
|
||||||
|
|
||||||
|
def payload_bytes(fr):
|
||||||
|
"""响应帧的原始载荷字节(Frame.payload 是 ASCII 解码视图, 二进制载荷请用本函数)"""
|
||||||
|
return fr.raw[3:-1]
|
||||||
|
|
||||||
|
|
||||||
|
def payload_u16_be(raw):
|
||||||
|
return (raw[0] << 8) | raw[1]
|
||||||
|
|
||||||
|
|
||||||
|
def payload_s16_be(raw):
|
||||||
|
v = payload_u16_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):
|
||||||
|
"""电源实测命令成功应答 -> 人读文本; 非电源命令/失败/载荷异常返回 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':
|
||||||
|
mv = payload_u16_be(raw)
|
||||||
|
return f'{mv} mV ({mv / 1000:.2f} V)'
|
||||||
|
ma = payload_s16_be(raw)
|
||||||
|
return f'{ma:+d} mA'
|
||||||
|
|
||||||
|
|
||||||
def sum_crc(data):
|
def sum_crc(data):
|
||||||
"""帧内第 2 字节(板类型)起累加到末 payload 字节, 取低 8 位(不含包头与 CRC)"""
|
"""帧内第 2 字节(板类型)起累加到末 payload 字节, 取低 8 位(不含包头与 CRC)"""
|
||||||
return sum(data) & 0xFF
|
return sum(data) & 0xFF
|
||||||
@@ -136,15 +185,29 @@ class FrameParser:
|
|||||||
del self._buf[:1]
|
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):
|
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:
|
if fr.type_code == 0xF0:
|
||||||
text = fr.payload or 'ok'
|
text = pl or 'ok'
|
||||||
else:
|
else:
|
||||||
text = TYPE_NAMES.get(fr.type_code, f'TYPE=0x{fr.type_code:02X}')
|
text = TYPE_NAMES.get(fr.type_code, f'TYPE=0x{fr.type_code:02X}')
|
||||||
if fr.payload:
|
if pl:
|
||||||
text += f' {fr.payload}'
|
text += f' {pl}'
|
||||||
m = re.search(r'err:(\d+)', fr.payload)
|
m = re.search(r'err:(\d+)', pl)
|
||||||
if m:
|
if m:
|
||||||
text += f'({DLPC_ERR_NAMES.get(int(m.group(1)), "未知错误码")})'
|
text += f'({DLPC_ERR_NAMES.get(int(m.group(1)), "未知错误码")})'
|
||||||
return text
|
return text
|
||||||
@@ -158,8 +221,8 @@ class DLPC485:
|
|||||||
回调在后台线程触发, GUI 侧需自行投递回主线程(见 hdmi_gui 的轮询队列)。
|
回调在后台线程触发, GUI 侧需自行投递回主线程(见 hdmi_gui 的轮询队列)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, on_event, on_status):
|
def __init__(self, on_event, on_status, on_frame=None):
|
||||||
self.on_event, self.on_status = on_event, on_status
|
self.on_event, self.on_status, self.on_frame = on_event, on_status, on_frame
|
||||||
self.q = queue.Queue()
|
self.q = queue.Queue()
|
||||||
self._target = None # 想态 (port, baud, bytesize, parity, stopbits)
|
self._target = None # 想态 (port, baud, bytesize, parity, stopbits)
|
||||||
self._open_key = None # 当前已打开串口的参数元组
|
self._open_key = None # 当前已打开串口的参数元组
|
||||||
@@ -252,7 +315,9 @@ class DLPC485:
|
|||||||
port, baud, bytesize=bytesizes.get(dbsize, serial.EIGHTBITS),
|
port, baud, bytesize=bytesizes.get(dbsize, serial.EIGHTBITS),
|
||||||
parity=parities.get(parity, serial.PARITY_NONE),
|
parity=parities.get(parity, serial.PARITY_NONE),
|
||||||
stopbits=stopbits.get(sbits, serial.STOPBITS_ONE),
|
stopbits=stopbits.get(sbits, serial.STOPBITS_ONE),
|
||||||
timeout=0.15)
|
timeout=0.15,
|
||||||
|
write_timeout=1.0) # 写超时: 转换器停滞时 write 会
|
||||||
|
# 永久阻塞并卡死线程, 必须设上限
|
||||||
self.parser = FrameParser()
|
self.parser = FrameParser()
|
||||||
self._open_key = target
|
self._open_key = target
|
||||||
self.on_status(f'已连接 {port} @ {baud} '
|
self.on_status(f'已连接 {port} @ {baud} '
|
||||||
@@ -261,7 +326,19 @@ class DLPC485:
|
|||||||
self.ser = None
|
self.ser = None
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._target = None
|
self._target = None
|
||||||
self.on_status(f'连接失败: {e}')
|
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:
|
if self.ser is not None:
|
||||||
try:
|
try:
|
||||||
item = None
|
item = None
|
||||||
@@ -276,10 +353,17 @@ class DLPC485:
|
|||||||
data = self.ser.read(256)
|
data = self.ser.read(256)
|
||||||
if data:
|
if data:
|
||||||
for fr in self.parser.feed(data):
|
for fr in self.parser.feed(data):
|
||||||
|
if self.on_frame is not None:
|
||||||
|
self.on_frame(fr) # 结构化帧(二进制载荷场景用)
|
||||||
self.on_event(f'← {describe(fr)} '
|
self.on_event(f'← {describe(fr)} '
|
||||||
f'[{fr.raw.hex(" ").upper()}]')
|
f'[{fr.raw.hex(" ").upper()}]')
|
||||||
except OSError:
|
except OSError:
|
||||||
|
try:
|
||||||
|
self.ser.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self.ser = None
|
self.ser = None
|
||||||
|
self._open_key = None # 必须同步清除, 否则同参数重连被当作已打开而跳过
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._target = None
|
self._target = None
|
||||||
self.on_status('串口丢失(设备拔出?), 请重新连接')
|
self.on_status('串口丢失(设备拔出?), 请重新连接')
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ HDMI EDID 查看器 + 测试图案投屏 + 光机控制 — 入口与命令行
|
|||||||
配合 LT8619C(HDMI 转 RGB888)→ DLPC3421 转接板调试使用。
|
配合 LT8619C(HDMI 转 RGB888)→ DLPC3421 转接板调试使用。
|
||||||
|
|
||||||
模块划分(重构自原单文件):
|
模块划分(重构自原单文件):
|
||||||
app_gui.py CustomTkinter 主界面(双栏仪表盘)
|
app_fluent.py PySide6-Fluent-Widgets 主界面(双栏仪表盘)
|
||||||
display_win.py Win32 显示器枚举/分辨率/拓扑
|
display_win.py Win32 显示器枚举/分辨率/拓扑
|
||||||
edid_parse.py EDID 1.4 / CEA-861 解析
|
edid_parse.py EDID 1.4 / CEA-861 解析
|
||||||
patterns.py 测试图案全屏投屏窗口
|
patterns.py 测试图案全屏投屏窗口
|
||||||
@@ -45,9 +45,9 @@ from patterns import PATTERN_NAMES, show_pattern, show_pattern_image
|
|||||||
|
|
||||||
|
|
||||||
def run_gui(auto_close=0):
|
def run_gui(auto_close=0):
|
||||||
"""启动 CustomTkinter 主界面(实现在 app_gui.py)"""
|
"""启动 PySide6-Fluent-Widgets 主界面(实现在 app_fluent.py)"""
|
||||||
import app_gui
|
import app_fluent
|
||||||
app_gui.run_gui(auto_close)
|
app_fluent.run_gui(auto_close)
|
||||||
|
|
||||||
|
|
||||||
def run_dump(edid_fields=False):
|
def run_dump(edid_fields=False):
|
||||||
@@ -121,7 +121,8 @@ def run_dlpc(port, baud, cmds):
|
|||||||
print('← (35s 超时无响应)')
|
print('← (35s 超时无响应)')
|
||||||
rc = 1
|
rc = 1
|
||||||
else:
|
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:
|
if got.type_code != 0xF0:
|
||||||
rc = 1
|
rc = 1
|
||||||
ser.close()
|
ser.close()
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ BASE = os.path.dirname(SRC)
|
|||||||
RELEASE = os.path.join(BASE, 'release')
|
RELEASE = os.path.join(BASE, 'release')
|
||||||
ASSETS = os.path.join(BASE, 'assets')
|
ASSETS = os.path.join(BASE, 'assets')
|
||||||
|
|
||||||
PATTERN_GLOBS = ('bw_*.png', 'solid_*.png')
|
# CeramixNano*.png: 校准图案素材(用户提供, 二进制直存 assets/, 无生成脚本)
|
||||||
|
PATTERN_GLOBS = ('bw_*.png', 'solid_*.png', 'CeramixNano*.png')
|
||||||
|
|
||||||
|
|
||||||
def detect_version():
|
def detect_version():
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -73,5 +73,35 @@ for _name, cmd, _note in d.PRESETS:
|
|||||||
d.build_frame(cmd)
|
d.build_frame(cmd)
|
||||||
print(f'预设 {len(d.PRESETS)} 条全部可组帧: PASS')
|
print(f'预设 {len(d.PRESETS)} 条全部可组帧: PASS')
|
||||||
|
|
||||||
|
# 8) M303/M304(SQ52205 电源实测)回归向量: 上游真机抓取帧
|
||||||
|
check('build_frame(M303)', d.build_frame('M303').hex(' ').upper(),
|
||||||
|
'D5 01 04 4D 33 30 33 E8')
|
||||||
|
check('build_frame(M304)', d.build_frame('M304').hex(' ').upper(),
|
||||||
|
'D5 01 04 4D 33 30 34 E9')
|
||||||
|
frames = d.FrameParser().feed(bytes.fromhex('B5 F0 02 2E F4 14'))
|
||||||
|
check('M303 应答帧', len(frames), 1)
|
||||||
|
check('M303 电压(0x2EF4=12020mV)', d.format_power('M303', frames[0]),
|
||||||
|
'12020 mV (12.02 V)')
|
||||||
|
frames = d.FrameParser().feed(bytes.fromhex('B5 F0 02 00 34 26'))
|
||||||
|
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')
|
print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS')
|
||||||
sys.exit(1 if fails else 0)
|
sys.exit(1 if fails else 0)
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
# DLPilot 使用说明
|
||||||
|
|
||||||
|
**HDMI 源端调试 + 光机控制工具**
|
||||||
|
|
||||||
|
| 项目 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 适用版本 | v0.4.3(2026-10-13 发布) |
|
||||||
|
| 适用系统 | Windows 10 / 11 x64,免安装、无需 Python |
|
||||||
|
| 适用对象 | LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板的调试、测试与校准人员 |
|
||||||
|
| 文档日期 | 2026-10-14 |
|
||||||
|
|
||||||
|
DLPilot 是配套投影转接板调试的 PC 端工具,一台电脑同时完成两件事:
|
||||||
|
|
||||||
|
- **HDMI 源端**:查看板卡上报的 EDID、切换输出分辨率、向板卡全屏投放测试图案
|
||||||
|
(纯色、黑白测试图、自定义校准图);
|
||||||
|
- **光机控制**:通过 USB-RS485 控制光机——开关光机、设光强、查温度/电流、
|
||||||
|
PD 电源自检与功率读数、TEC 温控。
|
||||||
|
|
||||||
|
界面上左右两列分别对应这两个功能域,互不干扰,可以只用其中一列。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[TOC]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 安装(从压缩包开始)
|
||||||
|
|
||||||
|
### 1.1 拿到安装包
|
||||||
|
|
||||||
|
安装包为 `DLPilot-v0.4.3-win64.zip`(约 67 MB),由我方对接工程师提供。
|
||||||
|
收到后请先核对文件名中的版本号,本文档对应 **v0.4.3**;若版本更新,以随包说明为准。
|
||||||
|
|
||||||
|
### 1.2 解压
|
||||||
|
|
||||||
|
1. 在电脑上选择一个**可写**的本地目录,例如 `D:\Tools\`(不要解压到只读目录、
|
||||||
|
网络共享盘或需要管理员权限才能写入的系统目录,原因见 1.5 节);
|
||||||
|
2. 右键压缩包 →「全部解压」→ 目标路径填 `D:\Tools\`,确认后完成解压;
|
||||||
|
3. 解压后得到一个 `DLPilot` 文件夹。
|
||||||
|
|
||||||
|
> **注意:不要在压缩包预览窗口里直接双击运行 exe。**
|
||||||
|
> 直接从压缩包内运行时程序被解压到临时目录,你配置的自定义图片和串口参数
|
||||||
|
> 会写到临时目录并随其清空而丢失。务必先完整解压,再运行解压后的 exe。
|
||||||
|
|
||||||
|
解压后的文件夹内容如下:
|
||||||
|
|
||||||
|
| 文件 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `DLPilot.exe` | 主程序,双击运行(单文件自包含,光机控制所需串口库已内置) |
|
||||||
|
| `pattern_slots.json` | 运行配置:自定义图案栏位、测试卡区域、串口参数记忆。**删除它会丢失你的全部本地配置** |
|
||||||
|
| `bw_*.png`(5 张) | 640×360 黑白测试图:西门子星、频率扫描条纹、渐变棋盘、波带片、综合测试卡 |
|
||||||
|
| `solid_*.png`(10 张) | 800×600 纯色测试图(R/G/B 各 55/AA/FF + 三原色),供自定义栏位加载 |
|
||||||
|
| `CeramixNano-*.png`(如包含) | 光机校准图案(black / white / contrast / circle_640p / caliPoint / sharpness),新版本安装包已预置到自定义栏位 |
|
||||||
|
| `README.md` | 随包说明(与本文档内容同源) |
|
||||||
|
|
||||||
|
### 1.3 首次启动
|
||||||
|
|
||||||
|
双击 `DLPilot.exe`:
|
||||||
|
|
||||||
|
- 首次启动比之后慢一两秒(单文件程序需要先自解压),属正常现象;
|
||||||
|
- 程序没有数字签名,首次运行 Windows SmartScreen 可能弹出
|
||||||
|
「Windows 已保护你的电脑」——点「更多信息」→「仍要运行」即可;
|
||||||
|
- 个别杀毒软件可能对加壳的单文件 exe 误报,如遇拦截请放行或将安装目录加入白名单。
|
||||||
|
|
||||||
|
程序启动后出现双栏主界面(见第 3 节),即安装成功。
|
||||||
|
|
||||||
|
### 1.4 升级新版本
|
||||||
|
|
||||||
|
1. 关闭正在运行的 DLPilot;
|
||||||
|
2. 解压新版本压缩包到**新的空目录**(不要直接覆盖旧目录,避免新旧文件混杂);
|
||||||
|
3. 如需保留旧版的自定义栏位与串口记忆:把旧目录里的 `pattern_slots.json`
|
||||||
|
和你自己添加的图片文件复制到新目录,覆盖同名文件即可;
|
||||||
|
4. 双击新目录的 `DLPilot.exe`。
|
||||||
|
|
||||||
|
### 1.5 为什么必须放在可写目录
|
||||||
|
|
||||||
|
程序的所有本地数据(`pattern_slots.json` 配置、你添加的自定义图片)都保存在
|
||||||
|
**exe 所在目录**,不写注册表、不写用户目录。放在只读目录会导致配置保存失败。
|
||||||
|
两份不同目录的安装(比如两台工位电脑、或同一台电脑解压了两份)各自独立保存配置,互不影响。
|
||||||
|
|
||||||
|
## 2. 硬件连接
|
||||||
|
|
||||||
|
### 2.1 整体链路
|
||||||
|
|
||||||
|
```
|
||||||
|
PC(DLPilot) ──HDMI──→ LT8619C(HDMI→RGB888)──RGB888 视频──→ DLPC3421 光机
|
||||||
|
PC(DLPilot) ──USB-RS485──→ GD32E230 主控 ───I2C 控制(0x1B)──→ DLPC3421 光机
|
||||||
|
```
|
||||||
|
|
||||||
|
HDMI 与 RS485 接的是**同一块转接板**:LT8619C 负责把 HDMI 视频转换为 RGB888;
|
||||||
|
GD32E230 是板上的主控 MCU,负责整板逻辑控制——DLPilot 的 RS485 命令由它解析,
|
||||||
|
再经 I2C 总线控制光机(DLPC3421)。
|
||||||
|
|
||||||
|
### 2.2 HDMI 连接
|
||||||
|
|
||||||
|
1. 用 HDMI 线把电脑显卡输出接到转接板 HDMI 输入;
|
||||||
|
2. 板卡正常上电。板卡 shadow EDID 只声明 640×360@60 一种模式;
|
||||||
|
3. 如果 Windows 没有自动点亮这块"显示器"(显示设置里看不到):
|
||||||
|
启动 DLPilot,工具检测到是本系列板卡(EDID 厂商签名)接入但未进桌面时,
|
||||||
|
会自动把它以扩展屏方式拉回桌面;仍不行时在 Windows「显示设置」里手动改为「扩展这些显示器」。
|
||||||
|
|
||||||
|
### 2.3 RS485 连接(光机控制)
|
||||||
|
|
||||||
|
1. USB-RS485 转换器接电脑 USB,其 A/B 端子对应接转接板的 RS485 A/B 端子;
|
||||||
|
2. 串口参数固定 **115200 波特率、8 数据位、无校验、1 停止位(115200 8N1)**,
|
||||||
|
软件默认值即为此,一般无需修改;
|
||||||
|
3. 板卡供电建议使用满足 **PD 12V / ≥3A** 的电源适配器——软件的"电源自检"
|
||||||
|
就是以该挡位为正常判据(见 5.3 节)。
|
||||||
|
|
||||||
|
### 2.4 上电时序(重要)
|
||||||
|
|
||||||
|
DLPC3421 光机芯片启动需要几秒钟。**上电后立即发开光机/设光强/查电流命令会收到
|
||||||
|
拒绝应答(0xFF),这是正常保护,不是故障**——稍等几秒待光机就绪后再发。
|
||||||
|
|
||||||
|
## 3. 界面总览
|
||||||
|
|
||||||
|
主窗口为双栏仪表盘,顶栏可切换 Dark / Light / System 外观(跟随系统为默认),
|
||||||
|
顶栏右侧「关于」按钮内有软件版本信息:
|
||||||
|
|
||||||
|
| 左列(HDMI 源端) | 右列(光机控制 RS485) |
|
||||||
|
|---|---|
|
||||||
|
| 显示器选择:显示器下拉 + 刷新 | 串口选择:串口下拉 + 波特率等参数 + 连接 |
|
||||||
|
| 支持的模式(默认折叠):分辨率下拉 + 应用 / 恢复 + 时序明细 | 电源检查:电源自检 + 功率检测 |
|
||||||
|
| EDID 信息(默认折叠):解算结果一览 | 串口数据:收发日志(译码 + hex)+ 清空 |
|
||||||
|
| 测试图案:内置图案 / R·G·B 色值 / 自定义与校准栏位 / 测试卡区域选择 | 光机命令:快捷按钮排 + 预设下拉 + 自定义输入 + 温度读取 |
|
||||||
|
| EDID 原始数据:hex 显示 + 逐字节解析 | 快捷滑条:光强 / 定时 / TEC 温度 |
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*图 1 主界面与快速开始要点*
|
||||||
|
|
||||||
|
红框与编号对应四个上手要点:
|
||||||
|
|
||||||
|
1. **选显示器**:左上「显示器选择」卡的下拉框选中接转接板的显示器
|
||||||
|
(Generic PnP Monitor),下方会显示当前实际输出分辨率/刷新率;
|
||||||
|
多屏或热插拔后点「刷新」重新枚举;
|
||||||
|
2. **投测试图案**:现在的转接板**上电默认打开光机**,无需先连串口——
|
||||||
|
在「测试图案」卡选好测试卡区域后即可直接投放指定图案
|
||||||
|
(点「停止」按钮或按 ESC 退出全屏);
|
||||||
|
3. **连串口**:需要控制光机光强、查温度/电流等才需要串口——USB-RS485
|
||||||
|
转换器接好后,在「串口」卡选口(参数保持默认 115200 8N1)点「连接」;
|
||||||
|
4. **控光机**:常用光机控制都集中在「光机命令」卡(开/关光机、查版本、
|
||||||
|
软件重启、TEC、温度读取、预设下拉与自定义输入),下方「快捷滑条」
|
||||||
|
可便捷调光强、定时投光、TEC 温度。
|
||||||
|
|
||||||
|
「支持的模式」「EDID 信息」两张卡片默认折叠,点卡片标题展开。
|
||||||
|
左列只操作显卡输出(不碰光机),右列只操作光机(不碰显卡)。
|
||||||
|
|
||||||
|
## 4. HDMI 源端操作(左列)
|
||||||
|
|
||||||
|
### 4.1 选择显示器
|
||||||
|
|
||||||
|
1. 左上下拉框列出当前所有显示器,选中**接转接板的那一块**;
|
||||||
|
多屏时注意核对型号/分辨率,不要选成你的主屏;
|
||||||
|
2. 热插拔后列表没刷新时点「刷新」重新枚举;
|
||||||
|
3. 下拉框内容与系统识别一致;如对序号有疑问,可在 Windows「显示设置」中
|
||||||
|
点「识别」核对。
|
||||||
|
|
||||||
|
### 4.2 查看支持的模式 / 切换分辨率
|
||||||
|
|
||||||
|
1. 展开「支持的模式」卡片,下拉框列出显卡按该屏 EDID 提供的全部模式
|
||||||
|
(板卡正常时可见 640×360@60);
|
||||||
|
2. 选中模式后点「应用」即切换。**切换是临时的**:不写注册表,重启电脑或
|
||||||
|
重新插拔即还原,不会永久改动你的桌面设置;
|
||||||
|
3. 测试完成后点「恢复」,回到本次打开软件时的原始模式;
|
||||||
|
4. 卡片同时显示选中模式的时序明细,可核对像素时钟、刷新率等。
|
||||||
|
|
||||||
|
### 4.3 查看 EDID
|
||||||
|
|
||||||
|
- 「EDID 信息」卡片:显示从该显示器读到的 EDID 解算结果——厂商、型号、
|
||||||
|
尺寸、Gamma、输入类型、支持的全部模式、首选时序完整参数、校验和;
|
||||||
|
- 「EDID 原始数据」卡片:显示 128 字节(含扩展块)原始 hex;
|
||||||
|
点右上「逐字节解析」按钮,可按偏移逐字段展开基础块与 CTA-861 扩展块,
|
||||||
|
每个字段附原始字节,适合核对板卡 EDID 是否按规格上报。
|
||||||
|
|
||||||
|
### 4.4 投放测试图案
|
||||||
|
|
||||||
|
1. 先在「测试卡区域」选择投放区域:
|
||||||
|
- `640x360`(默认)——与光机面板原生分辨率 1:1,**链路验证/校准用这个**;
|
||||||
|
- `800x600`——对应 demo 板首选时序;
|
||||||
|
- `full`——铺满所选显示器的整个屏幕;
|
||||||
|
2. 点「测试图案」卡片中的按钮即全屏投放到所选显示器:
|
||||||
|
- 内置图案:纯红、纯绿、纯蓝、黑白棋盘格、网格线(程序直接绘制);
|
||||||
|
- R/G/B 三行 `55 / AA / FF` 色值按钮:按通道着色的纯色,用途见 7.2 节;
|
||||||
|
- 自定义/校准图片栏位:见 4.5 节;
|
||||||
|
3. 投放新图案会自动替换当前旧图案;按 **ESC** 键或点「停止测试图像」退出全屏。
|
||||||
|
|
||||||
|
### 4.5 自定义图片栏位(含校准图案)
|
||||||
|
|
||||||
|
「测试图案」卡片提供 **10 个可配置栏位**,每个栏位一个图案按钮 + 一个齿轮按钮:
|
||||||
|
|
||||||
|
1. 点某个栏位的**齿轮**按钮,弹出配置对话框;
|
||||||
|
2. 填写栏位名称(显示在按钮上),选择图片文件(推荐 PNG;JPG/BMP/WebP
|
||||||
|
依运行环境而定,PNG 最稳妥);
|
||||||
|
3. 保存后图片自动**复制到 exe 所在目录**,栏位按钮即可点按投放。
|
||||||
|
因此不要事后手动移动/删除 exe 旁的图片文件,否则栏位失效;
|
||||||
|
4. 安装包已预置常用栏位(黑白测试图与校准图案,具体以包内为准),
|
||||||
|
可按上述步骤改成你自己的图。
|
||||||
|
|
||||||
|
栏位配置、测试卡区域选择会自动保存在 exe 旁的 `pattern_slots.json`。
|
||||||
|
|
||||||
|
## 5. 光机控制操作(右列)
|
||||||
|
|
||||||
|
### 5.1 连接串口
|
||||||
|
|
||||||
|
1. 「串口选择」卡片:下拉框列出所有串口,显示为 `COMx + 设备描述`。
|
||||||
|
多路 USB 转换器(如 CH344 四路)每一路描述不同(serial-A/B/C/D),
|
||||||
|
据此选口;描述相同时软件自动附物理位置区分;
|
||||||
|
2. 波特率等参数默认 115200 8N1,与板卡固件约定一致,保持默认即可;
|
||||||
|
3. 点「连接」。连接失败会给出简短原因(串口被占用/不存在等),
|
||||||
|
完整错误信息进收发日志;
|
||||||
|
4. 上次使用的串口与参数会被记住,下次启动自动带出(预置配置带的串口如与
|
||||||
|
你现场不符,第一次使用时改成你的实际串口即可)。
|
||||||
|
|
||||||
|
### 5.2 收发日志
|
||||||
|
|
||||||
|
「串口数据 · 收发日志」卡片实时显示:
|
||||||
|
|
||||||
|
- `→` 发出的命令;
|
||||||
|
- `←` 收到的应答:**可读译码 + 原始 hex** 对照显示;
|
||||||
|
- 错误自动附释义,例如 `err:3(I2C NACK)` 表示光机侧 I2C 无应答;
|
||||||
|
- 点「清空」清屏。日志超过 500 行自动滚动淘汰旧条目,长时间联机不会卡顿。
|
||||||
|
|
||||||
|
协议帧格式(判读 hex 时参考):命令帧 `D5 01 LEN <ASCII命令> CRC`,
|
||||||
|
应答帧 `B5 TYPE LEN <payload> CRC`,CRC 为自第 2 字节起的累加和低 8 位。
|
||||||
|
|
||||||
|
### 5.3 电源检查
|
||||||
|
|
||||||
|
「电源检查」卡片有两个按钮:
|
||||||
|
|
||||||
|
- **电源自检**:自动依次查询供电协议(M301)→ PD 挡位最大电流(M302)→
|
||||||
|
实测母线电压(M303),并按实测电压就近判定 PD 固定挡(5/9/12/15/20/28V):
|
||||||
|
|
||||||
|
| 显示 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| 绿底,如 `FPDO: 12V@3A` | 供电正常:12V 挡且电流 ≥3A |
|
||||||
|
| 红底,如 `FPDO: 9V@3A` | 挡位不符:电源没有协商到 12V 挡,请换符合要求的 PD 电源 |
|
||||||
|
| `ERR` | 实测电压不落在任何标准 PD 挡内,检查供电 |
|
||||||
|
|
||||||
|
说明:取电芯片的电压配置寄存器只写不可读,挡位只能靠实测电压判定,
|
||||||
|
所以以自检结果为准,而不是电源标称。
|
||||||
|
|
||||||
|
- **功率检测**:自动依次读母线电压(M303)/ 负载电流(M304)/ 负载功率(M305),
|
||||||
|
读数框显示 电压(V)、电流(mA)、功率(W)。功率量程 0~295 W。
|
||||||
|
|
||||||
|
### 5.4 光机命令
|
||||||
|
|
||||||
|
「光机命令」卡片从上到下:
|
||||||
|
|
||||||
|
1. **快捷按钮排**(最常用操作一键完成):
|
||||||
|
`开光机`、`关光机`、`查版本`、`软件重启`、`开TEC`、`关TEC`、`TEC33.0°C`;
|
||||||
|
2. **温度读取**:`查DMD温度`、`查LAMP温度`,应答数值(0.1°C 单位换算后)
|
||||||
|
实时填入按钮旁的显示框;
|
||||||
|
3. **预设下拉**:常用命令清单,选中即发送;
|
||||||
|
4. **自定义输入框**:手动输入任意命令(见第 6 节命令表),**回车即发送**。
|
||||||
|
|
||||||
|
### 5.5 快捷滑条
|
||||||
|
|
||||||
|
「快捷滑条」卡片三个滑条,拖到目标值后点对应按钮下发:
|
||||||
|
|
||||||
|
- **光强**:拖动后点「下发电流值」(有效范围 54–800,RGB 三路同值);
|
||||||
|
- **定时**:设定毫秒数后点「开光机(定时)」——开光机并在到时后自动关,
|
||||||
|
0 表示不限时;上限 32700 ms;
|
||||||
|
- **TEC 温度**:滑条/输入框设定凝露点 200–350(对应 20.0–35.0°C),
|
||||||
|
点「下发温度值」。
|
||||||
|
|
||||||
|
滑条旁的数值输入框支持直接键入,回车后自动限制到有效范围并与滑条同步。
|
||||||
|
|
||||||
|
## 6. 光机命令参考
|
||||||
|
|
||||||
|
| 命令 | 功能 | 应答示例 / 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `M1` | 协议握手 | `ok` |
|
||||||
|
| `M730S0` / `M730S1` | 开 / 关光机 | `ok` |
|
||||||
|
| `M730S0T2000` | 开光机并定时 2000ms 自动关 | `ok`;定时 ≤32700 ms |
|
||||||
|
| `M731S100` | 设光强(54–800,RGB 同值) | `ok`;失败回 `err:R..G..B..` |
|
||||||
|
| `M732` | 查三路电流设置值 | `okR110G110B110` |
|
||||||
|
| `M737` | 查投光是否结束 | 投光进行中会**延迟应答**,属正常 |
|
||||||
|
| `M201` / `M202` | 查 DMD / LAMP 温度 | `okTL:246` = 24.6°C |
|
||||||
|
| `M7S0` / `M7S1` | TEC 自动控温 **开** / 关 | 注意 **S0 是开**,方向与直觉相反 |
|
||||||
|
| `M8S330` | TEC 凝露点 33.0°C | 范围 200–350,超范围回 `err:OL` |
|
||||||
|
| `M301` | 查供电协议(PD/QC3.0/EPR…) | ASCII 文本 |
|
||||||
|
| `M302` | 查 PD 挡位最大电流 | mA(50mA/LSB 已换算) |
|
||||||
|
| `M303` | 查母线电压(实测) | 如 12020 mV = 12.02 V |
|
||||||
|
| `M304` | 查负载电流(实测) | mA,带符号(正=正向) |
|
||||||
|
| `M305` | 查负载功率(实测) | mW,软件换算显示为 W |
|
||||||
|
| `M999` | 查固件版本 | 如 `okv20.0.0.1` |
|
||||||
|
| `M888` | 软件重启 | `ok` 后约 100ms 复位 |
|
||||||
|
|
||||||
|
> **注意:**
|
||||||
|
>
|
||||||
|
> 1. 光机上电后几秒内处于启动期,此时 `M730/M731/M732` 会被就绪门禁拒绝(回 0xFF),
|
||||||
|
> 等几秒再发即可;
|
||||||
|
> 2. USB-RS485 转换器是半双工的:本机发送期间板上回包不会回环显示,属正常现象;
|
||||||
|
> 3. 设光强等带数值的命令,超范围值会被板卡拒绝,请按表中范围下发。
|
||||||
|
|
||||||
|
## 7. 典型操作流程
|
||||||
|
|
||||||
|
### 7.1 首次整链路验证(新板卡点灯)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. HDMI 接转接板,USB-RS485 接板卡 A/B,板卡上电(PD 12V/≥3A),等数秒
|
||||||
|
2. 启动 DLPilot → 「刷新」→ 显示器下拉选中板卡(640x360@60)
|
||||||
|
3. 光机控制:选串口 → 连接 → 点「查版本」(M999)
|
||||||
|
应答 okv... 即 RS485→光机 链路通;无应答见第 9 节排查
|
||||||
|
4. 点「电源自检」:绿底 FPDO: 12V@3A 即供电合格
|
||||||
|
5. 测试卡区域选 640x360(与光机面板原生 1:1);转接板目前上电默认开光机,
|
||||||
|
可直接投图,若光机已被关闭再点「开光机」
|
||||||
|
6. 投放测试图案目视检查 → 设光强/查温度电流(按需)
|
||||||
|
7. 测完:「关光机」→ 分辨率「恢复」→ 停止测试图像
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 RGB 通道与位序检查
|
||||||
|
|
||||||
|
用「测试图案」里的 R/G/B 行色值按钮(`55 / AA / FF` 三档):
|
||||||
|
|
||||||
|
1. **纯红/纯绿/纯蓝**:验证 RGB 三通道映射是否正确
|
||||||
|
(硬件约定 R=D[23:16]、G=D[15:8]、B=D[7:0],投纯红时仅 R 通道应有输出);
|
||||||
|
2. **55 与 AA 配对**:`0x55`(01010101)与 `0xAA`(10101010)互为字节镜像,
|
||||||
|
专用于检出位序颠倒——位交换设置(0x606E)正确时图像应为 50% 灰阶占空比;
|
||||||
|
3. **FF**:全高基准,配合电流读数核对满幅输出。
|
||||||
|
|
||||||
|
### 7.3 校准图案投放
|
||||||
|
|
||||||
|
1. 「测试卡区域」选 `640x360`(校准图按光机原生分辨率制作);
|
||||||
|
2. 安装包已预置校准栏位(black / white / contrast / circle_640p / caliPoint /
|
||||||
|
sharpness,以包内为准);如需换用自己的校准图,按 4.5 节齿轮按钮配置;
|
||||||
|
3. 依次点栏位按钮投放,按校准规程判读(新图案自动替换旧图案,无需先停止);
|
||||||
|
4. 完成后 ESC 退出。
|
||||||
|
|
||||||
|
## 8. 数据判读速查
|
||||||
|
|
||||||
|
| 现象/读数 | 判读 |
|
||||||
|
|---|---|
|
||||||
|
| 开机后发命令回 `0xFF` | 光机启动中,就绪门禁拒绝,等几秒重发 |
|
||||||
|
| 日志出现 `err:3(I2C NACK)` | 光机侧 I2C 无应答:光机未上电/未就绪,或板卡故障 |
|
||||||
|
| 温度应答 `okTL:246` | 24.6°C(0.1°C 单位) |
|
||||||
|
| 电流应答 `okR110G110B110` | R/G/B 三路电流设置值均为 110 |
|
||||||
|
| 电源自检红底 `FPDO: 9V@3A` | PD 只协商到 9V 挡,未到 12V,更换/检查电源与线材 |
|
||||||
|
| 定时投光中 M737 无立即应答 | 正常:投光结束才应答,勿反复重发 |
|
||||||
|
|
||||||
|
## 9. 常见问题(FAQ)
|
||||||
|
|
||||||
|
**Q1:双击 exe 没反应 / 被 Windows 拦截。**
|
||||||
|
SmartScreen 弹窗点「更多信息」→「仍要运行」;杀毒软件误报时放行或加白名单;
|
||||||
|
仍不行则确认解压完整(exe 约 67 MB)。
|
||||||
|
|
||||||
|
**Q2:光机命令发出去没反应。**
|
||||||
|
按顺序排查:① 串口选对没有(115200 8N1)、A/B 线接对没有;② 先点「查版本」
|
||||||
|
(M999)验证链路,M999 都不通就是串口/接线问题;③ M999 通但开光机回 0xFF——
|
||||||
|
光机启动中,等几秒重试;④ 半双工转换器发送期间看不到回环是正常的。
|
||||||
|
|
||||||
|
**Q3:显示器下拉里找不到板卡。**
|
||||||
|
检查 HDMI 线与板卡供电;点「刷新」;启动 DLPilot 会自动把本系列板卡拉回扩展桌面,
|
||||||
|
仍不行到 Windows 显示设置手动改「扩展这些显示器」,然后回软件刷新。
|
||||||
|
|
||||||
|
**Q4:切了分辨率,桌面布局乱了。**
|
||||||
|
软件的切换是临时的:点「恢复」立即还原;重启电脑或重插 HDMI 也会还原。
|
||||||
|
不会永久改动系统设置。
|
||||||
|
|
||||||
|
**Q5:自定义栏位点了没图。**
|
||||||
|
该栏位未配置图片(点齿轮配置);或 exe 旁的图片文件被移动/删除——重新配置一次,
|
||||||
|
图片会重新复制到 exe 旁。
|
||||||
|
|
||||||
|
**Q6:串口下拉里好几个 COM 口,选哪个?**
|
||||||
|
下拉项带设备描述(如 USB-SERIAL CH340 / serial-A~D),选你的 USB-RS485
|
||||||
|
转换器对应的口;描述相同的会附物理位置。拿不准就拔插一次看哪个口出现/消失。
|
||||||
|
|
||||||
|
**Q7:两台电脑/两个目录各解压了一份,配置会串吗?**
|
||||||
|
不会。配置保存在各自 exe 旁的 `pattern_slots.json`,互相独立。
|
||||||
|
|
||||||
|
**Q8:软件用久了变卡。**
|
||||||
|
v0.4.2 及更早版本存在长会话卡顿问题,v0.4.3 已修复;请确认用的是 v0.4.3
|
||||||
|
(顶栏「关于」可查版本),旧版本请升级。
|
||||||
|
|
||||||
|
**Q9:换电脑要带走哪些东西?**
|
||||||
|
整个安装目录打包即可;至少要 `DLPilot.exe` + `pattern_slots.json`(含你的栏位
|
||||||
|
与串口记忆)+ 你添加的自定义图片。
|
||||||
|
|
||||||
|
## 10. 版本沿革
|
||||||
|
|
||||||
|
| 版本 | 日期 | 要点 |
|
||||||
|
|---|---|---|
|
||||||
|
| v0.4.3(当前) | 2026-10-13 | 修复长会话越用越卡、命令点击无响应的问题(收发日志积压);新增日志回归自检 |
|
||||||
|
| v0.4.2 | 2026-09-28 | 收发日志「清空」按钮归位;恢复光机快捷按钮排(开/关光机、查版本、TEC 等) |
|
||||||
|
| v0.4.1 | 2026-09-28 | 修复切换 Light/Dark 主题后色值按钮底色丢失;串口连接失败改为简短提示,不再撑破窗口 |
|
||||||
|
| v0.4.0 | 2026-09-24 | 界面重写为双栏仪表盘(Fluent 风格),功能基线版本 |
|
||||||
|
|
||||||
|
## 附录:技术支持
|
||||||
|
|
||||||
|
遇到本文未覆盖的问题,请联系我方对接工程师反馈:
|
||||||
|
软件版本号(顶栏「关于」)、操作步骤、收发日志截图(含原始 hex),
|
||||||
|
光机问题请同时提供「查版本」(M999)与温度读数。
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# 本机虚拟环境(根 .gitignore 已覆盖 build/ dist/ *.spec __pycache__/)
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# 构建日志(测试结果由 unittest 实时输出, 不留档)
|
||||||
|
build-log*.txt
|
||||||
|
|
||||||
|
# 验证脚本生成的截图
|
||||||
|
validation/*.png
|
||||||
@@ -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 与高级色彩数据解析。
|
||||||
|
- 与实际输出时序和桥接芯片寄存器联合诊断。
|
||||||
@@ -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**:适合高刷新率和高分辨率显示设备,作为下一阶段扩展。
|
||||||
@@ -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*`),本次未实际上传。
|
||||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
@@ -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)])
|
||||||
@@ -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**:适合高刷新率和高分辨率显示设备,作为下一阶段扩展。
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
PySide6==6.11.2
|
||||||
|
PySide6-Fluent-Widgets==1.11.3
|
||||||
|
pyinstaller==6.22.2
|
||||||
|
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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))
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@echo off
|
||||||
|
cd /d "%~dp0"
|
||||||
|
start "" ".venv\Scripts\pythonw.exe" "src\main.py"
|
||||||
@@ -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()
|
||||||
@@ -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)"
|
||||||
@@ -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 字节",
|
||||||
|
"范围描述符有扩展偏移,频率范围显示原始字段,暂未应用偏移"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
# I2C-Inject — LT8619C 寄存器择机注入工具
|
# 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/`,刷入
|
> 更新:2026-09-03。由两部分组成:**RP2040 注入器固件**(`mcu-rp2040/`,刷入
|
||||||
> BPI-Pico-RP2040,MicroPython)+ **PC 端 GUI**(`i2c_inject_gui.py` / 打包版
|
> BPI-Pico-RP2040,MicroPython)+ **PC 端 GUI**(`i2c_inject_gui.py` / 打包版
|
||||||
> `I2C-Inject.exe`)。可在 LT8619C demo 板的 I2C 总线上安全地在线读写寄存器。
|
> `I2C-Inject.exe`)。可在 LT8619C demo 板的 I2C 总线上安全地在线读写寄存器。
|
||||||
|
|||||||
@@ -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 可用)
|
||||||
@@ -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 不入库 |
|
||||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -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 |
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
@@ -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})
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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}')
|
||||||
@@ -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()
|
||||||
@@ -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('全部通过')
|
||||||
@@ -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('全部通过')
|
||||||
@@ -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))
|
||||||
@@ -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'
|
||||||
@@ -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)。
|
上游主仓库(固件):[LT8619C_DLPC3421_HDMI](https://gitea.hulk.wang/hulk/LT8619C_DLPC3421_HDMI)。
|
||||||
|
|
||||||
## 工具一览
|
## 工具一览
|
||||||
|
|
||||||
| 工具 | 用途 | 获取 |
|
| 图标 | 工具 | 用途 | 获取 |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| [DLPilot](DLPilot/) | HDMI 源端(EDID 查看/逐字节解析、分辨率切换、纯色与测试图案全屏投屏)+ 光机控制(RS485→GD32 网关→DLPC3421:开关光机/设光强/查温度电流) | 到 [Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases) 下 `DLPilot-vX.Y.Z-win64.zip`,解压即用;详见 [DLPilot/README.md](DLPilot/README.md) |
|
| <img src="DLPilot/src/logos/logo_dlpilot.png" width="64" alt="DLPilot 图标"/> | [DLPilot](DLPilot/) | HDMI 源端(EDID 查看/逐字节解析、分辨率切换、纯色与测试图案全屏投屏)+ 光机控制(RS485→GD32 网关→DLPC3421:开关光机/设光强/查温度电流) | 到 [Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases) 下 `DLPilot-vX.Y.Z-win64.zip`,解压即用;详见 [DLPilot/README.md](DLPilot/README.md) |
|
||||||
| [I2C-Inject](I2C-Inject/) | LT8619C 寄存器在线读写:RP2040 注入器在 demo 板 I2C 总线上择机注入(避让主控轮询,写后自动读回校验) | 需配合 RP2040 固件 `rp2040_inject.py`;详见 [I2C-Inject/README.md](I2C-Inject/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)
|
- 发布版 zip 开箱即用(Windows 10/11 x64,无需安装 Python)
|
||||||
- 源码运行:Python ≥3.10;DLPilot 命令行/EDID 纯标准库,GUI 需
|
- 源码运行:Python ≥3.10;DLPilot 命令行/EDID 纯标准库,GUI 需
|
||||||
`py -m pip install customtkinter`(自定义图片 JPG/BMP 可选 Pillow、光机控制可选 pyserial);
|
`py -m pip install PySide6-Fluent-Widgets`(自定义图片 JPG/BMP 可选 Pillow、光机控制可选 pyserial);
|
||||||
I2C-Inject 需 `py -m pip install pyserial`
|
EDID-viewer GUI 需 `py -m pip install -r EDID-viewer/requirements.txt`(PySide6 + PySide6-Fluent-Widgets);
|
||||||
- 重新打包:`DLPilot/src/build_exe.py`(自动安装 PyInstaller);I2C-Inject 目录内 `python build_exe.py`
|
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/ 生成资产(可再生成)** →
|
每个工具内统一三区:**src/ 源代码(人写)** → **assets/ 生成资产(可再生成)** →
|
||||||
**release/ 打包与发布产物(机器生成)**;README.md 面向使用者,AGENTS.md 面向 AI/Agent(工程约定与构建规程)。
|
**release/ 打包与发布产物(机器生成)**;README.md 面向使用者,AGENTS.md 记录工程约束与开发进度,
|
||||||
|
仓库整体的开发需求与进度管理见根目录 `agent.md`。
|
||||||
|
|
||||||
```
|
```
|
||||||
LT8619C_Debug_Tools/
|
HW-Debug-Tools/
|
||||||
├── DLPilot/
|
├── DLPilot/
|
||||||
│ ├── src/ # 源码 + 打包/发布脚本 + 图标源
|
│ ├── src/ # 源码 + 打包/发布脚本 + 图标源
|
||||||
│ ├── assets/ # 生成的测试图案 PNG(gen_*.py 产物)
|
│ ├── assets/ # 生成的测试图案 PNG(gen_*.py 产物)
|
||||||
│ ├── release/ # 打包 exe + 运行配置(入库);发布 zip(不入库)
|
│ ├── release/ # 交付物(exe/zip 不入库,本地保留;只上传 Gitea Release 附件)
|
||||||
│ ├── README.md # 使用说明
|
│ ├── README.md # 使用说明
|
||||||
│ └── AGENTS.md # AI/Agent 工程档案
|
│ └── AGENTS.md # 工程档案
|
||||||
├── I2C-Inject/ # PC 端 GUI + RP2040 固件 + 打包 exe + 图标 + README
|
├── 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
|
├── LICENSE
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## 发布流程(DLPilot)
|
## 发布流程(DLPilot / EDID-Viewer / SerialMate / I2C-Lab 同一套流水线)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd DLPilot/src
|
cd DLPilot/src # 或 EDID-viewer/src、SerialMate/src、I2C-Lab/src
|
||||||
python build_exe.py # 打包 → ../release/DLPilot.exe
|
python build_exe.py # 打包 → ../release/<工具>.exe
|
||||||
python package_release.py <版本> # → ../release/DLPilot-v<版本>-win64.zip
|
python package_release.py <版本> # → ../release/<工具>-v<版本>-win64.zip
|
||||||
python publish_gitea.py DLPilot-v<版本>-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN)
|
python publish_gitea.py <工具>-v<版本>-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
每个工具独立打标签发布(`DLPilot-v*` / `EDID-Viewer-v*` / `SerialMate-v*` /
|
||||||
|
`I2C-Lab-v*`),Release 列表按时间倒序混排于同一页面。
|
||||||
|
|
||||||
## 备注
|
## 备注
|
||||||
|
|
||||||
- 源码与打包产物(exe、pattern_slots.json)均入库;发布 zip 不入库,只上传 Gitea Release
|
- 源码与运行配置入库;exe/zip 等交付物不入库(本地保留),发布只上传 Gitea Release 附件
|
||||||
|
- `gd32e230f8_firmware_merge_tool/` 是 git 子模块([独立仓库](https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool),
|
||||||
|
代码与发布均在其仓内管理,本仓只锁定指针):
|
||||||
|
新克隆后需 `git submodule update --init` 拉取内容;
|
||||||
|
跟进其最新版用 `git submodule update --remote gd32e230f8_firmware_merge_tool`,随本仓提交新指针
|
||||||
- 两件工具的寄存器语义(0x606E 位交换 / 0x606D 整组顺序)均经 demo 板逻辑分析仪
|
- 两件工具的寄存器语义(0x606E 位交换 / 0x606D 整组顺序)均经 demo 板逻辑分析仪
|
||||||
双色交叉实测,过程数据与规程存于上游主仓库 `doc/architecture/`
|
双色交叉实测,过程数据与规程存于上游主仓库 `doc/architecture/`
|
||||||
- 更新:2026-09-11
|
- 全部工具图标(logo 家族:黑底 + 工具色圆角块 + 两行字)的约定与再生成见
|
||||||
|
[icons/README.md](icons/README.md),统一生成器 `icons/make_icon.py`
|
||||||
|
- 更新:2026-10-10
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
# 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`
|
||||||
|
(空格转 ` `,防富文本折叠连续空格破坏 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 范围预判不同)。
|
||||||
|
5a. **解析不吞数据(v0.1.2 起的产品语义)**:串口助手是本体、协议解析是附加层。
|
||||||
|
FrameParser 重同步丢弃的字节累积进 `garbage`,`link.SerialLink._dispatch_rx`
|
||||||
|
每批 `drain_garbage()` 无条件取走(未接回调也不许在解析器里无限累积),
|
||||||
|
经 `on_garbage(data, ts)` 上抛;GUI 把它并入 `('raw', ...)` 事件,与透传行
|
||||||
|
同格式显示。纯文本设备/波特率不符的乱码在解析模式下必须全可见。
|
||||||
|
例外:只匹配上前缀、还在等后续字节的"半帧"缓冲不算垃圾(可能补全成真帧),
|
||||||
|
字节流恰好在帧头后截断时会扣留至多一个前缀长度的字节——可接受,勿改成
|
||||||
|
超时强制吐出(会把慢速真帧撕碎)。改此语义需同步 selftest 8b / loop 3b。
|
||||||
|
6. **ID 位不校验只透出**:`Frame.code` 原样上抛(布局无 ID 时为 None,GUI 日志
|
||||||
|
与 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 # 协议回归(67 项, 改 protocol.py 后必跑)
|
||||||
|
python selftest_loop.py # 链路回环(21 项, 需 pyserial, 改 link.py 后必跑)
|
||||||
|
python selftest_gui.py # GUI 功能回归(68 项, 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.2(2026-10-16,接收语义修复:解析模式不再吞数据)
|
||||||
|
|
||||||
|
背景:用户接 COM25(非 DLPilot 协议设备)开解析模式,接收区一片空白——
|
||||||
|
`_dispatch_rx` 在解析模式只上抛完整合法帧,不成帧的字节被 FrameParser 静默
|
||||||
|
丢弃;用户明确产品定位:**首先是基础串口调试工具,自定义协议解析是附加项**,
|
||||||
|
附加能力不得影响本体功能。
|
||||||
|
|
||||||
|
已完成:
|
||||||
|
- protocol.py:FrameParser 丢弃字节不再销毁——重同步逐出的字节累积进
|
||||||
|
`garbage`,新增 `drain_garbage()` 取走清空;`clear()` 一并清垃圾;
|
||||||
|
`feed()` 返回值不变(旧用法零改动)。
|
||||||
|
- link.py:新增 `on_garbage(data, ts)` 回调(区别于透传模式的 `on_raw`,
|
||||||
|
语义可分但 GUI 同样并入 raw 事件显示);`_dispatch_rx` 每批 drain 无条件
|
||||||
|
执行(未接回调也不累积),有字节且接了回调才上抛,ts=同一批到达时刻。
|
||||||
|
- main.py:接线 `on_garbage → ('raw', ...)`;"解析协议帧(收)"补 tooltip
|
||||||
|
说明两模式语义;APP_VERSION 0.1.2。
|
||||||
|
- 例外语义(有意为之):只匹配前缀、等待后续字节的半帧缓冲不算垃圾
|
||||||
|
(可能补全成真帧);字节流恰在帧头后截断时扣留至多一个前缀长度。
|
||||||
|
- 自检扩充:selftest 8b(纯文本全透出/噪声+帧+尾字节/坏CRC帧字节全透出/
|
||||||
|
半帧头扣留后补全成帧)+10 项;selftest_loop 3b(解析模式非帧字节经
|
||||||
|
on_garbage 透出、回环 TX 帧只算非帧字节)+3 项;selftest_gui 5b
|
||||||
|
(on_garbage 接线+非帧字节日志行)+2 项;全部通过。
|
||||||
|
- README 协议解析段同步"解析不吞数据"语义;exe 重建(v0.1.2);
|
||||||
|
已发布:tag `SerialMate-v0.1.2` 指向 `8e91e66`,Release 附件
|
||||||
|
`SerialMate-v0.1.2-win64.zip`(63.6 MB)已上传核对。发布当日本机(联想
|
||||||
|
笔记本)到 1234 端口 TCP 能连但 banner 静默(22/443 正常,异机 N100
|
||||||
|
SSH 认证正常——服务端无恙,是本机网络路径对 1234 的选择性干扰),
|
||||||
|
push 改走 HTTPS+token 成功;TLS 抽风加 `--insecure` 老毛病不变。
|
||||||
|
|
||||||
|
待办 / 计划:无新增,沿用 v0.1.0 待办。
|
||||||
|
|
||||||
|
### v0.1.1(2026-10-10,接收路径修复:时间戳失真 + 显示延迟累积)
|
||||||
|
|
||||||
|
背景:FilmForge 固件联调(GD32F405 RS485 115200,M2 连续温度上报 2~10 帧/s)
|
||||||
|
发现日志时间戳成批挤压、显示延迟越跑越大;固件侧二分验证 100% 出帧,问题
|
||||||
|
独立存在于 SerialMate 显示层,见 `ISSUE_rx_timestamp_and_lag.md`。
|
||||||
|
|
||||||
|
已完成:
|
||||||
|
- **根因 1**(时间戳在 GUI 消费时刻打):时间戳改在 link 后台线程打
|
||||||
|
(字节到达/写入串口时刻),`on_status/on_frame/on_raw/on_tx` 签名均追加
|
||||||
|
尾参 ts 并随事件入队;`_timestamp(t)` 变为纯格式化函数。日志时间恢复为
|
||||||
|
真实到达时间。
|
||||||
|
- **根因 2**(接收日志无限增长 → UI 递减性卡顿 → 延迟累积):
|
||||||
|
- 接收日志批量插入:`_drain_events` 把本轮日志行攒入 `_rx_buf`,末尾
|
||||||
|
`_flush_rx` 一次 appendHtml(每行一个 `<p style="margin:0">`,
|
||||||
|
maximumBlockCount 裁旧行照常生效;`_esc_log` 空格转 ` ` 防富文本
|
||||||
|
折叠连续空格);`_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 解析侧双字节序兼容开关(当前按算法约定单序)。
|
||||||
@@ -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 失效);转义空格转 ` `
|
||||||
|
> 防富文本折叠连续空格;通道缓存放 `_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 卡顿成批挤压、显示延迟随运行时间增大;
|
||||||
|
修复后时间戳单调且间隔≈设定周期,长时间运行无卡顿增长。
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# 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 目录有引用说明)。
|
||||||
@@ -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}')
|
||||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -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 目录有引用说明)。
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
#!/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_garbage(data, ts) 解析模式: 不成帧的原始 bytes(重同步丢弃字节, 不吞数据);
|
||||||
|
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_garbage = 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)
|
||||||
|
# 不成帧的字节也透出: 串口助手是本体、协议解析是附加层, 解析不能
|
||||||
|
# 让收到的字节凭空消失(纯文本设备/波特率不符的乱码全在这里可见)。
|
||||||
|
# drain 必须无条件执行, 未接回调时也不能在解析器里无限累积。
|
||||||
|
junk = self.parser.drain_garbage()
|
||||||
|
if junk and self.on_garbage is not None:
|
||||||
|
self.on_garbage(junk, ts)
|
||||||
|
elif self.on_raw is not None:
|
||||||
|
self.on_raw(data, ts)
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
#!/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 长度,
|
||||||
|
负值视为伪帧头逐出 —— 伪帧头最多滞留一帧即被逐出重同步, 对调试工具可接受。
|
||||||
|
丢弃字节不销毁: 累积进 self.garbage, 由上层 drain_garbage() 取走透出 ——
|
||||||
|
本工具首先是串口助手, 协议解析是附加层, 不成帧的字节也必须可见。
|
||||||
|
(仅匹配上前缀、还在等后续字节的"半帧"缓冲不算垃圾, 可能补全成真帧)
|
||||||
|
"""
|
||||||
|
|
||||||
|
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()
|
||||||
|
self.garbage = bytearray() # 重同步丢弃的非帧字节(调用方定期取走)
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
self._buf.clear()
|
||||||
|
self.garbage.clear()
|
||||||
|
|
||||||
|
def drain_garbage(self):
|
||||||
|
"""取走累积的非帧字节(bytes)并清空; 解析模式不吞数据的取数入口"""
|
||||||
|
out = bytes(self.garbage)
|
||||||
|
self.garbage.clear()
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _drop_head(self):
|
||||||
|
"""丢缓冲首字节重同步; 丢弃字节记入 garbage(可能正是用户要看的输出)"""
|
||||||
|
self.garbage.append(self._buf[0])
|
||||||
|
del self._buf[:1]
|
||||||
|
|
||||||
|
def _aligned(self):
|
||||||
|
"""把缓冲对齐到前缀匹配; 字节不足且已匹配部分则保持等待(返回 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
|
||||||
|
self._drop_head()
|
||||||
|
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 为负 -> 伪帧头, 逐出重同步
|
||||||
|
self._drop_head()
|
||||||
|
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:
|
||||||
|
self._drop_head() # 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)
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
#!/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)
|
||||||
|
|
||||||
|
# 8b) 非帧字节不吞: 重同步丢弃的字节累积进 garbage 可整体取回(串口助手是本体,
|
||||||
|
# 协议解析只是附加层——纯文本设备接入时所有字节都从这里透出显示)
|
||||||
|
p = d.FrameParser(cfg)
|
||||||
|
check('纯文本不成帧', p.feed(b'hello serial\r\n'), [])
|
||||||
|
check('纯文本全入 garbage', p.drain_garbage(), b'hello serial\r\n')
|
||||||
|
check('garbage 取后清空', p.drain_garbage(), b'')
|
||||||
|
p = d.FrameParser(cfg)
|
||||||
|
frames = p.feed(b'\x00\x01' + good + b'tail')
|
||||||
|
check('噪声+帧+尾字节 帧数', len(frames), 1)
|
||||||
|
check('噪声与尾字节可取回', p.drain_garbage(), b'\x00\x01tail')
|
||||||
|
p = d.FrameParser(cfg)
|
||||||
|
p.feed(bytes(bad) + good)
|
||||||
|
check('坏CRC帧字节全透出', p.drain_garbage(), bytes(bad))
|
||||||
|
p = d.FrameParser(cfg)
|
||||||
|
p.feed(b'\xB5') # 只到帧头: 可能补全成真帧, 扣留不算垃圾
|
||||||
|
check('半帧头扣留待补全', p.drain_garbage(), b'')
|
||||||
|
check('补全后成帧', [f.code for f in p.feed(good[1:])], [0xF0])
|
||||||
|
|
||||||
|
# 9) 伪帧头与帧头字节入载荷
|
||||||
|
# 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)
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"""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)
|
||||||
|
|
||||||
|
# 5b) 解析模式非帧字节: link.on_garbage 与透传同走 raw 事件, 日志不丢任何字节
|
||||||
|
# (v0.1.2 起协议解析是附加层, 串口助手本体语义优先)
|
||||||
|
check('on_garbage 已接线', win.link.on_garbage is not None, True)
|
||||||
|
win.events.put(('raw', b'\xAB\xCD', _time.time()))
|
||||||
|
win._drain_events()
|
||||||
|
check('非帧字节日志行', win.text_rx.toPlainText().splitlines()[-1].endswith('AB CD'), True)
|
||||||
|
|
||||||
|
# 6) 未连接发送丢弃
|
||||||
|
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)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""链路自检(无硬件): pyserial loop:// 虚拟回环验证 组帧→发送→接收→解析→曲线提取
|
||||||
|
====================================================================
|
||||||
|
缺 pyserial 时跳过(退出码 0)。loop:// 回环口写即读, 发出的帧同样回到
|
||||||
|
接收侧, 但 TX 帧头(D5)不是 RX 帧头(B5), 会作为非帧字节经 on_garbage 透出
|
||||||
|
(v0.1.2 起解析模式不吞数据), 不影响判定。
|
||||||
|
|
||||||
|
用法: 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': [], 'garbage': []}
|
||||||
|
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_garbage = lambda b, ts: got['garbage'].append((b, 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)
|
||||||
|
|
||||||
|
# 3b) 解析模式下非帧字节不吞: 以 garbage 原样透出(带到达时刻)——
|
||||||
|
# 纯文本设备接入解析模式也全可见, 这是 v0.1.2 的核心语义
|
||||||
|
link.ser.write(b'plain text\n')
|
||||||
|
check('解析模式非帧字节透出',
|
||||||
|
wait_for(lambda: b'plain text\n'
|
||||||
|
in b''.join(b for b, _t in got['garbage'])), True)
|
||||||
|
if got['garbage']:
|
||||||
|
check('非帧字节携带到达时刻',
|
||||||
|
isinstance(got['garbage'][-1][1], float)
|
||||||
|
and 0 < time.time() - got['garbage'][-1][1] < 60, True)
|
||||||
|
# 回环回来的 TX 帧(D5 头)在解析模式下同样只算非帧字节, 不应进 frames
|
||||||
|
check('回环TX帧不算合法帧', all(fr.raw[0] == 0xB5 for fr, _t in got['frames']), True)
|
||||||
|
|
||||||
|
# 4) 透传模式切换: 关解析后收到原始字节
|
||||||
|
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)
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
# 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.2(2026-10-16)
|
||||||
|
- **更名**:原名 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)。
|
||||||
|
- **v0.1.2(2026-10-16)发布**:**解析模式不再吞数据**——FrameParser 重同步
|
||||||
|
丢弃字节累积 garbage 经 link `on_garbage` 上抛,GUI 与透传行同格式显示;
|
||||||
|
起因用户接非 DLPilot 协议设备开解析模式接收区空白,用户明确定位
|
||||||
|
"串口助手是本体、协议解析是附加项"(半帧前缀扣留语义有意保留,详见其
|
||||||
|
AGENTS.md 不变量 5a)。tag `SerialMate-v0.1.2` 指向 `8e91e66`,Release
|
||||||
|
附件 `SerialMate-v0.1.2-win64.zip`(63.6 MB)已上传核对。
|
||||||
|
|
||||||
|
### 2.4 I2C-Inject — ⏸ 暂停开发(2026-09-25)
|
||||||
|
- 已完成: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)有明确用途,勿顺手改**。
|
||||||