Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b58cdc42b6 | ||
|
|
21c1b7bcc1 | ||
|
|
144730fb3a | ||
|
|
ca898be299 | ||
|
|
511e7dfa44 | ||
|
|
6d1e2c6f8d | ||
|
|
a0d64c322a | ||
|
|
a666ed7c43 | ||
|
|
1f68665860 | ||
|
|
668a7e1ca5 | ||
|
|
bf9d70722c | ||
|
|
5d06e69a41 | ||
|
|
a3430d15e0 | ||
|
|
419c902a38 | ||
|
|
b1bb320b50 | ||
|
|
2d1d8f83b1 | ||
|
|
d991d57447 | ||
|
|
43d78de045 | ||
|
|
739fdeedf3 | ||
|
|
b7eda91ca7 | ||
|
|
763d80534c | ||
|
|
31fcd93607 | ||
|
|
df6a5f4343 | ||
|
|
32d0d26c5a | ||
|
|
afb6513e56 |
@@ -8,8 +8,10 @@ dist/
|
||||
*.spec
|
||||
|
||||
# 发布 zip: 可由 src/package_release.py 重新生成, 只上传 Gitea Release
|
||||
HDMI-Tool/release/*.zip
|
||||
DLPilot/release/*.zip
|
||||
|
||||
# 源码模式运行时的配置写入(config.BASE_DIR=src/), 与 release/ 下入库的预置栏位区分
|
||||
DLPilot/src/pattern_slots.json
|
||||
|
||||
# ZCode 客户端本地会话数据(计划留档等), 非项目配置
|
||||
.zcode/
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
仓库 `LT8619C_Debug_Tools` 是 LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板的**调试上位机工具集**,
|
||||
上游主仓库(固件)为 `hulk/LT8619C_DLPC3421_HDMI`(同 Gitea 实例 `gitea.hulk.wang`),
|
||||
本仓库本机路径 `E:\Hulk-Coding\LT8619C_DLPC3421_HDMI`。
|
||||
上游固件本机路径 `E:\Hulk-Coding\LT8619C_DLPC3421_HDMI`;工具仓库路径 `E:\Hulk-Coding\LT8619C_Debug_Tools`。
|
||||
DLPilot 工作在两端:HDMI **源端**(PC 显卡侧,EDID/分辨率/图案)与**光机控制端**
|
||||
(RS485 → 板载 GD32E230 网关 → DLPC3421)。寄存器语义判读基线(0x606E 位交换 /
|
||||
0x606D 整组顺序)与光机协议规程都在上游仓库 `doc/architecture/` 与 `protocols.md`,不在本仓库。
|
||||
@@ -34,15 +34,15 @@ DLPilot/
|
||||
|
||||
| 文件 | 角色 |
|
||||
|---|---|
|
||||
| `src/hdmi_gui.py` | 入口: argparse 命令行分发(--dump/--edid-fields/--test-pattern/--dlpc-cmd/--seconds), GUI 委托 app_gui |
|
||||
| `src/app_gui.py` | CustomTkinter 主界面: 双栏仪表盘(左 HDMI 源端五卡/右 光机四卡), 光强/定时快捷滑条 |
|
||||
| `src/hdmi_gui.py` | 入口: argparse 命令行分发(--dump/--edid-fields/--test-pattern/--dlpc-cmd/--seconds), GUI 委托 app_fluent |
|
||||
| `src/app_fluent.py` | PySide6-Fluent-Widgets 主界面: 双栏仪表盘(左 HDMI 源端五卡/右 光机五卡), 光强/定时快捷滑条 |
|
||||
| `src/display_win.py` | Win32 显示器枚举/分辨率切换/智能拓扑(纯 ctypes) |
|
||||
| `src/edid_parse.py` | EDID 1.4/CEA-861 解析(纯标准库, 上游 lt8619c_edid.c 以此为参考解码器) |
|
||||
| `src/patterns.py` | 测试图案全屏投屏窗口(tkinter, ESC 退出, 测试卡区域 1:1 居中) |
|
||||
| `src/config.py` | 运行目录定位(BASE_DIR)与 pattern_slots.json 读写(slots/area/serial 三键) |
|
||||
| `src/dlpc_rs485.py` | 光机 RS485 协议模块: 帧构造/增量解析/译码表/预设命令/串口线程(DLPC485) |
|
||||
| `src/selftest_rs485.py` | 光机协议自检(对照上游实测帧向量, 无硬件回归, 改协议代码后必跑) |
|
||||
| `src/build_exe.py` | PyInstaller 打包 → `release/DLPilot.exe`(onefile/noconsole, 内嵌图标+pyserial+customtkinter) |
|
||||
| `src/build_exe.py` | PyInstaller 打包 → `release/DLPilot.exe`(onefile/noconsole, 内嵌图标+pyserial+PySide6-Fluent-Widgets) |
|
||||
| `src/package_release.py` | 复制图案到 release/(平铺) → `release/DLPilot-v<ver>-win64.zip`(校验栏位引用完整性; release/ 与 zip 内容一致) |
|
||||
| `src/publish_gitea.py` | 建/复用 Gitea Release 并上传 zip(tag 默认取 zip 文件名) |
|
||||
| `src/hdmi_source.ps1/.bat` | 独立 PowerShell 命令行工具(自包含,与 GUI 无代码共享) |
|
||||
@@ -51,17 +51,18 @@ DLPilot/
|
||||
| `src/la_wave_analyzer*.py` | 逻辑分析仪 CSV 波形判读(2/3 通道,40MHz 像素时钟假设) |
|
||||
| `src/logos/make_ico.py` | svg/png → ico(写在其自身目录) |
|
||||
| `release/pattern_slots.json` | 预置栏位:西门子星/波带片/综合测试卡,area=640x360;`serial` 键记忆光机串口 |
|
||||
| `UI参考布局.svg/.png` | 界面布局设计稿(app_gui.py 双栏布局的规格来源, 随分支入库) |
|
||||
| `UI参考布局.svg/.png` | 界面布局设计稿(app_fluent.py 双栏布局的规格来源, 随分支入库) |
|
||||
|
||||
## 3. 代码不变量(改 hdmi_gui.py 必须维持)
|
||||
|
||||
1. **依赖分层**:CLI/显示/EDID/协议/配置模块(display_win/edid_parse/patterns/config/
|
||||
dlpc_rs485)纯标准库;customtkinter 是 GUI(app_gui)的**硬依赖**(装了才能开界面,
|
||||
CLI 不受影响)。可选依赖仍走"try-import + HAS_* 标志 + 缺失降级"模式:Pillow、
|
||||
pyserial——缺失时对应功能禁用/提示安装,其余功能必须照常工作。
|
||||
2. **模块边界**:hdmi_gui.py 只是入口, 不放实现; GUI 不直接碰 ctypes/winreg
|
||||
(显示器走 display_win, EDID 走 edid_parse); pyserial 只准在 dlpc_rs485.py 里 import
|
||||
(hdmi_gui/app_gui 经 `dlpc_rs485.HAS_SERIAL` 与 `DLPC485` 类间接使用)。
|
||||
1. **依赖分层**:CLI/显示/EDID/协议/配置模块(display_win/edid_parse/config/
|
||||
dlpc_rs485)纯标准库;PySide6-Fluent-Widgets 是 GUI(app_fluent)的**硬依赖**(装了
|
||||
才能开界面,CLI 不受影响),GUI 图案窗口使用 Qt,CLI 图案窗口由 patterns.py 使用 tkinter 创建。可选依赖
|
||||
仍走"try-import + HAS_* 标志 + 缺失降级"模式:Pillow、pyserial——缺失时对应功能
|
||||
禁用/提示安装,其余功能必须照常工作。
|
||||
2. **模块边界**:hdmi_gui.py 只是入口, 不放实现; GUI 的显示器/EDID 访问走 display_win/edid_parse;ctypes 仅用于
|
||||
单实例锁、Windows 标题栏与 frozen DLL 加载; pyserial 只准在 dlpc_rs485.py 里 import
|
||||
(hdmi_gui/app_fluent 经 `dlpc_rs485.HAS_SERIAL` 与 `DLPC485` 类间接使用)。
|
||||
3. **BASE_DIR 语义**(定义在 config.py):frozen(PyInstaller onefile)时
|
||||
`BASE_DIR=exe 所在目录`(`sys.executable`),源码运行时 `=config.py 所在目录`
|
||||
(即 src/)。所有用户数据(pattern_slots.json、自定义图片)都写/读自 BASE_DIR
|
||||
@@ -80,17 +81,18 @@ DLPilot/
|
||||
9. **EDID 来源是注册表**(`HKLM\SYSTEM\CurrentControlSet\Enum\DISPLAY\...\Device Parameters\EDID`),
|
||||
不是 WMI/DDC;exe 的 noconsole 版无 stdout,`--dump/--edid-fields` 只在源码/控制台版可用。
|
||||
10. **光机串口线程回调不直接碰 GUI**:DLPC485 的 on_event/on_status 在后台线程触发,
|
||||
必须经 `queue.Queue` + `root.after(80, …)` 轮询投递回主线程(现行 `_poll_dlpc` 模式,
|
||||
必须经 `queue.Queue` + Qt 定时器轮询投递回主线程(现行 `_poll_dlpc` 模式,
|
||||
与 I2C-Inject 的 Injector 同源);退出路径(关窗与 `--seconds` 自动关)都会走到
|
||||
`App.destroy()`,在那里 `dlpc.close()` 收线程。
|
||||
`App.closeEvent()`,在那里 `dlpc.close()` 收线程。
|
||||
11. **pattern_slots.json 格式**:`{'area', 'slots', 'serial'}` 三键共存;load 兼容旧版
|
||||
纯数组(无 area)与无 serial 键两种历史格式,save 永远写全三键。`serial` 子键为
|
||||
`{port, baud, bytesize(8/7/6/5), parity(N/E/O), stopbits(1/2)}`,旧配置缺子键按
|
||||
默认 8-N-1 补齐。
|
||||
12. **GUI = app_gui.py 双栏仪表盘**(规格:`DLPilot/UI参考布局.svg`):左列 HDMI 源端
|
||||
五卡(显示器/模式/EDID 信息/测试图案/hex),右列光机四卡(串口/数据/命令/滑条),
|
||||
顶栏 Dark/Light/System 切换(ttk.Treeview 颜色随动)。打包必须 `--collect-all
|
||||
customtkinter`(build_exe.py 已内置,勿删)——CTk 主题/字体数据缺失会让 exe 启动即崩。
|
||||
12. **GUI = app_fluent.py 双栏仪表盘**(规格:`DLPilot/UI参考布局.svg`):左列 HDMI 源端
|
||||
五卡(显示器/模式/EDID 信息/测试图案/hex),右列光机五卡(串口/数据/电源/命令/滑条),
|
||||
支持的模式和 EDID 信息默认折叠;顶栏 Dark/Light/System 切换。Light/Dark 同时更新
|
||||
Fluent 主题、Qt 调色板、容器样式与 Windows DWM 标题栏,勿只调用 setTheme。
|
||||
PD 协议成功显示绿色,FPDO 仍独立判定。打包必须收集 `qfluentwidgets` 资源,并加入 Windows 系统 `icuuc.dll`,否则 QtCore 可能被其它 ICU DLL 解析失败。
|
||||
|
||||
## 4. 领域语义(测试值都有含义,勿"顺手改")
|
||||
|
||||
@@ -101,11 +103,25 @@ DLPilot/
|
||||
**同时是转接板光机 DLPC3421 的 nHD 面板原生分辨率**(该板 shadow EDID 只声明 640x360@60),
|
||||
投 640x360 图案 + RS485 开光机 = 整链路 1:1 验证。
|
||||
- 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`(累加和校验)、错误码与**就绪门禁**
|
||||
(DLPC 未就绪时 M730/M731/M732 被拒回 0xFF)以上游 `protocols.md` 为唯一权威,
|
||||
本仓不维护协议文档副本;注意 **M7S0 是"开启"TEC 自动控温**(方向反直觉)、
|
||||
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/ 下执行)
|
||||
|
||||
@@ -114,7 +130,7 @@ python gen_bw_patterns.py ; python gen_solid_colors.py # 仅当改了图案生
|
||||
python build_exe.py # → ../release/DLPilot.exe(缺 PyInstaller 会自动 pip install)
|
||||
python build_exe.py --debug # 附加带控制台的 DLPilot-console.exe(调试 --dump 用)
|
||||
python package_release.py <版本> # → ../release/DLPilot-v<版本>-win64.zip
|
||||
python publish_gitea.py DLPilot-v<版本>-win64.zip --replace --insecure # 上传(需 GITEA_TOKEN)
|
||||
python publish_gitea.py ../release/DLPilot-v<版本>-win64.zip --replace --insecure # 上传(需 GITEA_TOKEN)
|
||||
```
|
||||
|
||||
发布约定:
|
||||
@@ -139,13 +155,12 @@ python publish_gitea.py DLPilot-v<版本>-win64.zip --replace --insecure # 上
|
||||
- GUI 与 hdmi_source.ps1 各自独立实现显示器枚举,行为可能微差(GUI 与 `-List` 序号一致,
|
||||
但与"设置"面板序号无保证);多屏操作前先确认序号;
|
||||
- `pattern_slots.json` 兼容两种历史格式,写出只写新格式(详见 §3.11);
|
||||
- CustomTkinter 6.x:`CTkComboBox` 不支持 `textvariable`(用 `.set()/.get()`);`CTkTextbox`
|
||||
的 `tag_config` 在部分版本不可用,app_gui 已 try/except 兜底为单色;同一容器内
|
||||
grid/pack 不可混用(卡片用 grid 挂进列,卡片内部一律 pack——`_card` 返回 (卡片, 内容));
|
||||
- 旧版 `app_gui.py` 仅保留作历史对照,当前入口使用 `app_fluent.py`;新增或修改界面时
|
||||
应遵循 PySide6-Fluent-Widgets 的控件和 Qt 布局,不要把旧版 CustomTkinter 依赖带回入口。
|
||||
|
||||
## 7. 验证清单(改完代码后)
|
||||
|
||||
1. `python -m py_compile src/*.py` —— 全模块可编译;
|
||||
1. `python -m compileall -q src`(在 DLPilot/ 下) —— 全模块可编译;
|
||||
2. `python selftest_rs485.py` —— 光机协议回归(改 dlpc_rs485.py 后必跑,向量来自上游 protocols.md);
|
||||
3. `python hdmi_gui.py --seconds 5` —— GUI 冒烟(自动开关);
|
||||
4. `python hdmi_gui.py --test-pattern Checker --area 800x600 --seconds 3` —— 投图链路;
|
||||
@@ -153,3 +168,12 @@ python publish_gitea.py DLPilot-v<版本>-win64.zip --replace --insecure # 上
|
||||
6. `python hdmi_gui.py --dlpc-cmd M999`(不带 --port)—— 应明确报错而非崩溃(无硬件时的优雅降级);
|
||||
7. `python build_exe.py` → 双击 release/DLPilot.exe 确认图标/双栏布局/投图/光机区渲染;
|
||||
8. `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 纳入同一个提交,确认远端标签指向该提交;zip 不入库。
|
||||
- `release/pattern_slots.json` 的本机串口记忆改动、外部 `EDID-viewer/` 不属于本次 UI 提交。
|
||||
发布使用仓库预置配置,临时打包后恢复本机文件,勿将运行态配置悄悄提交。
|
||||
@@ -8,7 +8,7 @@
|
||||
> 下载 `DLPilot-vX.Y.Z-win64.zip`,解压双击 `DLPilot.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
|
||||
> - **要改代码/重新打包**:见 §8、§9。
|
||||
>
|
||||
> 更新:2026-09-11
|
||||
> 更新:2026-09-24
|
||||
|
||||
---
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
| EDID 逐字节解析 | "EDID 原始数据"框右上"逐字节解析"按钮:按偏移逐字段展开基础块与 CTA-861 扩展块,每字段附原始字节 hex;命令行等价 `--edid-fields` |
|
||||
| 分辨率切换 | "分辨率"下拉列出显卡按该屏 EDID 提供的全部模式;**应用**=临时切换(不写注册表,重启/恢复即还原),**恢复**=回到本次会话切改前的模式 |
|
||||
| 测试图案投屏 | 全屏投到所选显示器,测试卡区域可选 `640x360 / 800x600 / full 铺满`;内置 纯红/纯绿/纯蓝/黑白棋盘格/网格线 + R/G/B 三行 55/AA/FF 纯色值按钮 + 3 个自定义图片栏位 |
|
||||
| 界面 | CustomTkinter 深色双栏仪表盘(左列 HDMI 源端 / 右列 光机控制),顶栏可切换 Dark/Light/System |
|
||||
| 界面 | PySide6-Fluent-Widgets 双栏仪表盘(左列 HDMI 源端 / 右列 光机控制),顶栏可切换 Dark/Light/System |
|
||||
| 光机控制 | RS485 → 板载 GD32E230 网关 → DLPC3421:开关光机(可定时)、设光强、查电流/温度/版本、TEC 控制、软件重启;光强/定时提供快捷滑条;收发日志带协议译码(见 §5) |
|
||||
| 智能显示拓扑 | 仅当检测到转接板(EDID 厂商 "XLS")接入但未进入桌面(Windows 卡在"仅电脑屏幕")时才强制扩展把它拉回;你主动选的"仅第二屏幕"等拓扑不会被改动 |
|
||||
| 命令行模式 | 全部功能可脚本化调用,供自动化测试(见 §6、§7) |
|
||||
@@ -52,16 +52,18 @@ DLPilot/
|
||||
```
|
||||
左列(HDMI 源端) 右列(光机控制 RS485)
|
||||
┌ 显示器选择区域 [刷新] ┐ ┌ 串口选择 + 波特率/8-N-1 参数/连接 ┐
|
||||
├ 支持的模式: 分辨率[应用][恢复] + 明细 ┐ ├ 串口数据 — 收发日志(译码+hex) ┤
|
||||
├ EDID 信息(表格) ┘ ├ 光机命令 — 快捷按钮+TEC │
|
||||
├ 测试图案 / 纯色 / 自定义 / 测试卡区域 │ 预设下拉 / 自定义 / 温度读取 │
|
||||
├ EDID 原始数据 [逐字节解析] ┘ └ 快捷滑条 — 光强/定时/TEC温度 ┘
|
||||
├ 支持的模式: 分辨率[应用][恢复] + 明细 ┐ ├ 电源检查 — PD自检/FPDO挡位/功率读数 ┤
|
||||
├ EDID 信息 ┘ ├ 串口数据 — 收发日志(译码+hex) ┤
|
||||
├ 测试图案 / 纯色 / 校准图案 / 测试卡区域 │ ├ 光机命令 — 快捷按钮+TEC │
|
||||
├ EDID 原始数据 [逐字节解析] ┘ │ 预设下拉 / 温度电流读取 / 自定义 │
|
||||
└─────────────────────────────┘ └ 快捷滑条 — 光强/定时/TEC温度 ┘
|
||||
```
|
||||
|
||||
1. 左上显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举;
|
||||
2. **切分辨率**:"支持的模式"卡片里选模式 → "应用";测完点"恢复";
|
||||
3. **投测试图**:"测试图案"卡片先选**测试卡区域**(640x360 / 800x600 / full)→
|
||||
点图案按钮全屏投放,按 **ESC** 或点击退出;R/G/B 纯色按钮(55/AA/FF,按通道着色)
|
||||
点图案按钮全屏投放(新图案自动替换旧图案),按 **ESC** 或点"停止测试图像"退出;
|
||||
R/G/B 纯色按钮(55/AA/FF,按通道着色)
|
||||
用于 RGB 位交换类测试;自定义图片点齿轮按钮配置(图片自动复制到 exe 旁),
|
||||
"校准图案"一排为 3D 光固化测试/校准图预留槽位;
|
||||
4. **光机控制**:右列选串口与参数(波特率/数据位/校验/停止位,默认 115200 8-N-1)→ 连接 →
|
||||
@@ -98,6 +100,12 @@ GUI"光机控制"区:选串口 → 连接 → 点快捷按钮或命令下拉/
|
||||
多口描述相同时自动附物理位置消歧。日志区实时显示 `→ 命令` / `← 译码结果 (原始 hex)`,
|
||||
错误自动附释义(如 `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` |
|
||||
@@ -105,6 +113,11 @@ GUI"光机控制"区:选串口 → 连接 → 点快捷按钮或命令下拉/
|
||||
| `M731S100` | 设光强 54-800(RGB 同值,写后读回复核) | `ok` / `err:R..G..B..` |
|
||||
| `M732` | 查三路电流设置值(0-65535) | `okR110G110B110` |
|
||||
| `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` |
|
||||
| `M737` | 查投光是否结束(投光中延迟应答) | `ok` |
|
||||
| `M7S0` / `M7S1` | TEC 自动控温开 / 关(注意 S0=开) | `ok` |
|
||||
@@ -185,7 +198,8 @@ python la_wave_analyzer3.py <capture.csv> # 3 通道(RGB 三组同拍, ch8/
|
||||
DLPilot/
|
||||
├── src/ # 源代码(人工维护区)
|
||||
│ ├── hdmi_gui.py # 入口与命令行模式(argparse 分发)
|
||||
│ ├── app_gui.py # CustomTkinter 主界面(双栏仪表盘)
|
||||
│ ├── app_fluent.py # PySide6-Fluent-Widgets 主界面(双栏仪表盘)
|
||||
│ ├── app_gui.py # 历史 CustomTkinter 界面(保留,不由入口加载)
|
||||
│ ├── display_win.py # Win32 显示器枚举/分辨率/智能拓扑(纯标准库)
|
||||
│ ├── edid_parse.py # EDID 1.4/CEA-861 解析(纯标准库)
|
||||
│ ├── patterns.py # 测试图案全屏投屏窗口
|
||||
@@ -210,11 +224,11 @@ DLPilot/
|
||||
│ └── *.zip # 发布包(不入库, 上传 Gitea Release)
|
||||
├── README.md # 本说明
|
||||
├── AGENTS.md # 面向 AI/Agent 的工程档案(结构/约定/构建规程)
|
||||
└── UI参考布局.svg/.png # 界面布局设计稿(app_gui.py 按此实现)
|
||||
└── UI参考布局.svg/.png # 界面布局设计稿(app_fluent.py 双栏布局参考)
|
||||
```
|
||||
|
||||
从源码运行:`python src/hdmi_gui.py`,需 Python ≥3.10;EDID/命令行纯标准库;
|
||||
GUI 需 customtkinter(`py -m pip install customtkinter`);光机控制需 pyserial;
|
||||
GUI 需 PySide6-Fluent-Widgets(`py -m pip install PySide6-Fluent-Widgets`);光机控制需 pyserial;
|
||||
自定义图片要 JPG/BMP/WebP 需可选 Pillow。
|
||||
|
||||
## 9. 构建与发布
|
||||
@@ -223,7 +237,7 @@ GUI 需 customtkinter(`py -m pip install customtkinter`);光机控制需 p
|
||||
cd src
|
||||
python selftest_rs485.py # 光机协议回归(改 dlpc_rs485.py 后必跑)
|
||||
python build_exe.py # 打包 → ../release/DLPilot.exe(自动装 PyInstaller;
|
||||
# 内置 pyserial/customtkinter, CTk 主题数据自动收集)
|
||||
# 内置 pyserial/PySide6-Fluent-Widgets, Fluent 主题资源自动收集)
|
||||
python build_exe.py --debug # 额外出一个带控制台的调试变体(--dump 可见输出)
|
||||
python package_release.py 0.2.0 # 组装 release/DLPilot-v0.2.0-win64.zip
|
||||
python publish_gitea.py DLPilot-v0.2.0-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN)
|
||||
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -8,7 +8,7 @@
|
||||
> 下载 `DLPilot-vX.Y.Z-win64.zip`,解压双击 `DLPilot.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
|
||||
> - **要改代码/重新打包**:见 §8、§9。
|
||||
>
|
||||
> 更新:2026-09-11
|
||||
> 更新:2026-09-24
|
||||
|
||||
---
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
| EDID 逐字节解析 | "EDID 原始数据"框右上"逐字节解析"按钮:按偏移逐字段展开基础块与 CTA-861 扩展块,每字段附原始字节 hex;命令行等价 `--edid-fields` |
|
||||
| 分辨率切换 | "分辨率"下拉列出显卡按该屏 EDID 提供的全部模式;**应用**=临时切换(不写注册表,重启/恢复即还原),**恢复**=回到本次会话切改前的模式 |
|
||||
| 测试图案投屏 | 全屏投到所选显示器,测试卡区域可选 `640x360 / 800x600 / full 铺满`;内置 纯红/纯绿/纯蓝/黑白棋盘格/网格线 + R/G/B 三行 55/AA/FF 纯色值按钮 + 3 个自定义图片栏位 |
|
||||
| 界面 | CustomTkinter 深色双栏仪表盘(左列 HDMI 源端 / 右列 光机控制),顶栏可切换 Dark/Light/System |
|
||||
| 界面 | PySide6-Fluent-Widgets 双栏仪表盘(左列 HDMI 源端 / 右列 光机控制),顶栏可切换 Dark/Light/System |
|
||||
| 光机控制 | RS485 → 板载 GD32E230 网关 → DLPC3421:开关光机(可定时)、设光强、查电流/温度/版本、TEC 控制、软件重启;光强/定时提供快捷滑条;收发日志带协议译码(见 §5) |
|
||||
| 智能显示拓扑 | 仅当检测到转接板(EDID 厂商 "XLS")接入但未进入桌面(Windows 卡在"仅电脑屏幕")时才强制扩展把它拉回;你主动选的"仅第二屏幕"等拓扑不会被改动 |
|
||||
| 命令行模式 | 全部功能可脚本化调用,供自动化测试(见 §6、§7) |
|
||||
@@ -52,16 +52,18 @@ DLPilot/
|
||||
```
|
||||
左列(HDMI 源端) 右列(光机控制 RS485)
|
||||
┌ 显示器选择区域 [刷新] ┐ ┌ 串口选择 + 波特率/8-N-1 参数/连接 ┐
|
||||
├ 支持的模式: 分辨率[应用][恢复] + 明细 ┐ ├ 串口数据 — 收发日志(译码+hex) ┤
|
||||
├ EDID 信息(表格) ┘ ├ 光机命令 — 快捷按钮+TEC │
|
||||
├ 测试图案 / 纯色 / 自定义 / 测试卡区域 │ 预设下拉 / 自定义 / 温度读取 │
|
||||
├ EDID 原始数据 [逐字节解析] ┘ └ 快捷滑条 — 光强/定时/TEC温度 ┘
|
||||
├ 支持的模式: 分辨率[应用][恢复] + 明细 ┐ ├ 电源检查 — PD自检/FPDO挡位/功率读数 ┤
|
||||
├ EDID 信息 ┘ ├ 串口数据 — 收发日志(译码+hex) ┤
|
||||
├ 测试图案 / 纯色 / 校准图案 / 测试卡区域 │ ├ 光机命令 — 快捷按钮+TEC │
|
||||
├ EDID 原始数据 [逐字节解析] ┘ │ 预设下拉 / 温度电流读取 / 自定义 │
|
||||
└─────────────────────────────┘ └ 快捷滑条 — 光强/定时/TEC温度 ┘
|
||||
```
|
||||
|
||||
1. 左上显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举;
|
||||
2. **切分辨率**:"支持的模式"卡片里选模式 → "应用";测完点"恢复";
|
||||
3. **投测试图**:"测试图案"卡片先选**测试卡区域**(640x360 / 800x600 / full)→
|
||||
点图案按钮全屏投放,按 **ESC** 或点击退出;R/G/B 纯色按钮(55/AA/FF,按通道着色)
|
||||
点图案按钮全屏投放(新图案自动替换旧图案),按 **ESC** 或点"停止测试图像"退出;
|
||||
R/G/B 纯色按钮(55/AA/FF,按通道着色)
|
||||
用于 RGB 位交换类测试;自定义图片点齿轮按钮配置(图片自动复制到 exe 旁),
|
||||
"校准图案"一排为 3D 光固化测试/校准图预留槽位;
|
||||
4. **光机控制**:右列选串口与参数(波特率/数据位/校验/停止位,默认 115200 8-N-1)→ 连接 →
|
||||
@@ -98,6 +100,12 @@ GUI"光机控制"区:选串口 → 连接 → 点快捷按钮或命令下拉/
|
||||
多口描述相同时自动附物理位置消歧。日志区实时显示 `→ 命令` / `← 译码结果 (原始 hex)`,
|
||||
错误自动附释义(如 `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` |
|
||||
@@ -105,6 +113,11 @@ GUI"光机控制"区:选串口 → 连接 → 点快捷按钮或命令下拉/
|
||||
| `M731S100` | 设光强 54-800(RGB 同值,写后读回复核) | `ok` / `err:R..G..B..` |
|
||||
| `M732` | 查三路电流设置值(0-65535) | `okR110G110B110` |
|
||||
| `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` |
|
||||
| `M737` | 查投光是否结束(投光中延迟应答) | `ok` |
|
||||
| `M7S0` / `M7S1` | TEC 自动控温开 / 关(注意 S0=开) | `ok` |
|
||||
@@ -185,7 +198,8 @@ python la_wave_analyzer3.py <capture.csv> # 3 通道(RGB 三组同拍, ch8/
|
||||
DLPilot/
|
||||
├── src/ # 源代码(人工维护区)
|
||||
│ ├── hdmi_gui.py # 入口与命令行模式(argparse 分发)
|
||||
│ ├── app_gui.py # CustomTkinter 主界面(双栏仪表盘)
|
||||
│ ├── app_fluent.py # PySide6-Fluent-Widgets 主界面(双栏仪表盘)
|
||||
│ ├── app_gui.py # 历史 CustomTkinter 界面(保留,不由入口加载)
|
||||
│ ├── display_win.py # Win32 显示器枚举/分辨率/智能拓扑(纯标准库)
|
||||
│ ├── edid_parse.py # EDID 1.4/CEA-861 解析(纯标准库)
|
||||
│ ├── patterns.py # 测试图案全屏投屏窗口
|
||||
@@ -210,11 +224,11 @@ DLPilot/
|
||||
│ └── *.zip # 发布包(不入库, 上传 Gitea Release)
|
||||
├── README.md # 本说明
|
||||
├── AGENTS.md # 面向 AI/Agent 的工程档案(结构/约定/构建规程)
|
||||
└── UI参考布局.svg/.png # 界面布局设计稿(app_gui.py 按此实现)
|
||||
└── UI参考布局.svg/.png # 界面布局设计稿(app_fluent.py 双栏布局参考)
|
||||
```
|
||||
|
||||
从源码运行:`python src/hdmi_gui.py`,需 Python ≥3.10;EDID/命令行纯标准库;
|
||||
GUI 需 customtkinter(`py -m pip install customtkinter`);光机控制需 pyserial;
|
||||
GUI 需 PySide6-Fluent-Widgets(`py -m pip install PySide6-Fluent-Widgets`);光机控制需 pyserial;
|
||||
自定义图片要 JPG/BMP/WebP 需可选 Pillow。
|
||||
|
||||
## 9. 构建与发布
|
||||
@@ -223,7 +237,7 @@ GUI 需 customtkinter(`py -m pip install customtkinter`);光机控制需 p
|
||||
cd src
|
||||
python selftest_rs485.py # 光机协议回归(改 dlpc_rs485.py 后必跑)
|
||||
python build_exe.py # 打包 → ../release/DLPilot.exe(自动装 PyInstaller;
|
||||
# 内置 pyserial/customtkinter, CTk 主题数据自动收集)
|
||||
# 内置 pyserial/PySide6-Fluent-Widgets, Fluent 主题资源自动收集)
|
||||
python build_exe.py --debug # 额外出一个带控制台的调试变体(--dump 可见输出)
|
||||
python package_release.py 0.2.0 # 组装 release/DLPilot-v0.2.0-win64.zip
|
||||
python publish_gitea.py DLPilot-v0.2.0-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN)
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"area": "640x360",
|
||||
"slots": [
|
||||
{
|
||||
"name": "西门子星",
|
||||
"file": "bw_star.png"
|
||||
},
|
||||
{
|
||||
"name": "BW_ZONE",
|
||||
"file": "bw_zoneplate.png"
|
||||
},
|
||||
{
|
||||
"name": "BW_TEST",
|
||||
"file": "bw_testcard.png"
|
||||
},
|
||||
{
|
||||
"name": "自定义4",
|
||||
"file": null
|
||||
},
|
||||
{
|
||||
"name": "自定义5",
|
||||
"file": null
|
||||
},
|
||||
{
|
||||
"name": "自定义6",
|
||||
"file": null
|
||||
},
|
||||
{
|
||||
"name": "自定义7",
|
||||
"file": null
|
||||
},
|
||||
{
|
||||
"name": "自定义8",
|
||||
"file": null
|
||||
},
|
||||
{
|
||||
"name": "自定义9",
|
||||
"file": null
|
||||
},
|
||||
{
|
||||
"name": "自定义10",
|
||||
"file": null
|
||||
}
|
||||
],
|
||||
"serial": {
|
||||
"port": "COM15",
|
||||
"baud": 115200,
|
||||
"bytesize": 8,
|
||||
"parity": "N",
|
||||
"stopbits": 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,869 @@
|
||||
#!/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
|
||||
|
||||
# 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, setTheme,
|
||||
)
|
||||
|
||||
import config
|
||||
import display_win
|
||||
import dlpc_rs485
|
||||
import edid_parse
|
||||
import patterns
|
||||
|
||||
APP_NAME = "DLPilot"
|
||||
APP_VERSION = "0.4.0"
|
||||
REPO_URL = "https://gitea.hulk.wang/hulk/LT8619C_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):
|
||||
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)
|
||||
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)
|
||||
title.setStyleSheet("font-size: 24px; font-weight: 700;")
|
||||
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)
|
||||
button.setStyleSheet(f"PushButton {{ background: {color}; color: {'black' if color == '#00FF00' else 'white'}; }}")
|
||||
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)
|
||||
params.addWidget(self.lbl_dlpc_st)
|
||||
params.addStretch(1)
|
||||
body.addLayout(params)
|
||||
layout.addWidget(card)
|
||||
|
||||
card, body = self._card("串口数据 · 收发日志(译码 + 原始 hex)", self)
|
||||
row = QHBoxLayout()
|
||||
row.addStretch(1)
|
||||
row.addWidget(self._button("清空", self._dlpc_clear))
|
||||
body.addLayout(row)
|
||||
self.txt_dlpc = self._text()
|
||||
self.txt_dlpc.setMinimumHeight(230)
|
||||
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)
|
||||
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):
|
||||
try:
|
||||
while True:
|
||||
kind, value = self._dlpc_log_q.get_nowait()
|
||||
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 queue.Empty: pass
|
||||
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)
|
||||
if self.txt_dlpc.blockCount() > 500:
|
||||
cursor = self.txt_dlpc.textCursor(); cursor.movePosition(cursor.Start); cursor.select(cursor.BlockUnderCursor); cursor.removeSelectedText(); cursor.deleteChar()
|
||||
self.txt_dlpc.verticalScrollBar().setValue(self.txt_dlpc.verticalScrollBar().maximum())
|
||||
|
||||
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()
|
||||
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,8 +33,14 @@ MONO = ('Consolas', 12)
|
||||
BOLD = ('Microsoft YaHei UI', 13, '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_VERSION = '0.3.0'
|
||||
APP_VERSION = '0.3.6'
|
||||
REPO_URL = 'https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools'
|
||||
|
||||
|
||||
@@ -57,23 +63,44 @@ def _apply_dropdown_theme():
|
||||
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):
|
||||
"""入口: 配置外观并启动主窗口"""
|
||||
ctk.set_appearance_mode('System')
|
||||
_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()
|
||||
|
||||
|
||||
class App(ctk.CTk):
|
||||
def __init__(self, auto_close=0):
|
||||
super().__init__()
|
||||
self.title('DLPilot — 3D 光固化光机调试台 (EDID · 测试图案 · RS485 光机控制)')
|
||||
self.title(f'DLPilot v{APP_VERSION} — 3D 光固化光机调试台 '
|
||||
f'(EDID · 测试图案 · RS485 光机控制)')
|
||||
try: # 任务栏独立分组
|
||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID('hulk.DLPilot')
|
||||
except Exception:
|
||||
pass
|
||||
self.geometry('1440x920')
|
||||
self.minsize(1180, 800)
|
||||
self.geometry('1440x1010') # 右列新增电源检查卡, 窗口加高
|
||||
self.minsize(1180, 860)
|
||||
ic = get_icon_path()
|
||||
if ic:
|
||||
try:
|
||||
@@ -88,12 +115,21 @@ class App(ctk.CTk):
|
||||
self._orig_modes = {} # device -> 切改前的原始 DEVMODEW
|
||||
# 光机控制链路: 回调发生在串口线程, 经队列投递回 GUI 主线程轮询
|
||||
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._poll_job = None
|
||||
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._load_displays()
|
||||
# 主窗口按 ESC 也能关闭所有投屏(投屏窗口失焦时的兜底出口)
|
||||
self.bind('<Escape>', lambda _e: patterns.close_all())
|
||||
if dlpc_rs485.HAS_SERIAL:
|
||||
self._poll_dlpc()
|
||||
if auto_close > 0:
|
||||
@@ -202,13 +238,17 @@ class App(ctk.CTk):
|
||||
self.txt_modes.configure(state='disabled')
|
||||
|
||||
# -- EDID 信息 --
|
||||
# CTkTextbox 而非 CTkScrollableFrame+逐行 Label: 后者 40+ 个画布控件,
|
||||
# 窗口最大化时逐个重绘是卡顿主因; 文本框整体重排, 开销低一个量级
|
||||
card, c = self._card(col, 'EDID 信息')
|
||||
card.grid(row=2, column=0, sticky='nsew', pady=(10, 0))
|
||||
self.edid_scroll = ctk.CTkScrollableFrame(c, height=150)
|
||||
self.edid_scroll.pack(fill='both', expand=True)
|
||||
self.txt_edid = ctk.CTkTextbox(c, font=MONO, wrap='none')
|
||||
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 或"停止测试图像"退出',
|
||||
button='停止测试图像', command=patterns.close_all)
|
||||
card.grid(row=3, column=0, sticky='ew', pady=(10, 0))
|
||||
row1 = ctk.CTkFrame(c, fg_color='transparent')
|
||||
row1.pack(fill='x')
|
||||
@@ -315,6 +355,39 @@ class App(ctk.CTk):
|
||||
self._dlpc_log('帧格式: D5 01 LEN <命令> CRC / B5 TYPE LEN <payload> CRC, '
|
||||
'累加和校验; 全部命令见 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.. 应答解析)
|
||||
cur_frame = ctk.CTkFrame(self, fg_color='transparent')
|
||||
@@ -325,7 +398,7 @@ class App(ctk.CTk):
|
||||
command=self._query_current).pack(side='left', padx=(6, 0))
|
||||
card, c = self._card(col,'光机命令 — 预设 / 自定义 / TEC',
|
||||
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.pack(fill='x')
|
||||
for name, cmd in (('开光机', 'M730S0'), ('关光机', 'M730S1'),
|
||||
@@ -382,7 +455,7 @@ class App(ctk.CTk):
|
||||
|
||||
# -- 快捷滑条 --
|
||||
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.pack(fill='x')
|
||||
ctk.CTkLabel(rowa, text='光强(54-800):').pack(side='left')
|
||||
@@ -499,16 +572,13 @@ class App(ctk.CTk):
|
||||
self._set_text(self.txt_hex, edid_parse.hex_dump(d['edid']))
|
||||
|
||||
def _clear_edid_rows(self):
|
||||
for w in self.edid_scroll.winfo_children():
|
||||
w.destroy()
|
||||
self._set_text(self.txt_edid, '')
|
||||
|
||||
def _add_edid_row(self, k, v):
|
||||
row = ctk.CTkFrame(self.edid_scroll, fg_color='transparent')
|
||||
row.pack(fill='x', pady=1)
|
||||
ctk.CTkLabel(row, text=k, width=132, anchor='w',
|
||||
text_color='gray60').pack(side='left')
|
||||
ctk.CTkLabel(row, text=v, anchor='w').pack(side='left',
|
||||
fill='x', expand=True)
|
||||
self.txt_edid.configure(state='normal')
|
||||
self.txt_edid.insert('end', f'{k:<16}{v}\n')
|
||||
self.txt_edid.see('end')
|
||||
self.txt_edid.configure(state='disabled')
|
||||
|
||||
def _apply_mode(self):
|
||||
d = self._cur_display()
|
||||
@@ -676,6 +746,13 @@ class App(ctk.CTk):
|
||||
# ============================================================
|
||||
# 光机控制(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):
|
||||
# 温度查询应答: okTL:<0.1°C>, 依发送顺序归入 DMD/LAMP 实时显示框
|
||||
m = re.search(r'← okTL:(-?\d+)', line)
|
||||
@@ -721,6 +798,8 @@ class App(ctk.CTk):
|
||||
else:
|
||||
color = 'gray60'
|
||||
self.lbl_dlpc_st.configure(text=text, text_color=color)
|
||||
if text.startswith(('已断开', '串口丢失')):
|
||||
self._serial_reset_state()
|
||||
elif kind == 'temp':
|
||||
if text.startswith('DMD'):
|
||||
self.var_dmd.set(text)
|
||||
@@ -728,6 +807,11 @@ class App(ctk.CTk):
|
||||
self.var_lamp.set(text)
|
||||
elif kind == 'current':
|
||||
self.var_current.set(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:
|
||||
pass
|
||||
@@ -774,6 +858,7 @@ class App(ctk.CTk):
|
||||
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:
|
||||
@@ -792,10 +877,28 @@ class App(ctk.CTk):
|
||||
config.save_slots(self.slots, self.area_var.get()) # 记住本次串口
|
||||
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):
|
||||
if not self.dlpc.is_connected():
|
||||
self.lbl_dlpc_st.configure(text='未连接串口', text_color='#E07A3F')
|
||||
return
|
||||
if self._pd_seq or self._pwr_seq:
|
||||
# 序列进行中: 手动命令的应答帧会被序列错认领(它不区分应答属于哪条
|
||||
# 命令, 来一帧消费一步), 值会错位 —— 拒绝并提示, 序列结束再发
|
||||
self._dlpc_log(f'序列进行中, 命令已忽略: {cmd}')
|
||||
return
|
||||
self.dlpc.send(cmd)
|
||||
|
||||
def _dlpc_send_preset(self):
|
||||
@@ -810,6 +913,141 @@ class App(ctk.CTk):
|
||||
if 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):
|
||||
"""滑条数值输入框回车: 校验并限幅 54-800, 同步回滑条"""
|
||||
try:
|
||||
@@ -875,9 +1113,13 @@ class App(ctk.CTk):
|
||||
self._dlpc_send(f'M8S{v}')
|
||||
|
||||
def destroy(self):
|
||||
try: # 取消待触发的轮询回调, 避免销毁后 Tcl 报错
|
||||
try: # 取消待触发的轮询/自检/功率超时回调, 避免销毁后 Tcl 报错
|
||||
if 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:
|
||||
pass
|
||||
try: # 两种退出路径(关窗/--seconds 自动关)都收掉串口线程
|
||||
@@ -3,8 +3,8 @@
|
||||
"""
|
||||
把 hdmi_gui.py 打包成单文件 exe(工具名: DLPilot)
|
||||
================================
|
||||
使用 PyInstaller --onefile --noconsole: Python 解释器 + 标准库 + tkinter
|
||||
全部打进一个 exe, 目标机器无需安装任何依赖。
|
||||
使用 PyInstaller --onefile --noconsole: Python 解释器、PySide6 和
|
||||
PySide6-Fluent-Widgets 全部打进一个 exe, 目标机器无需安装依赖。
|
||||
|
||||
用法:
|
||||
python build_exe.py 自动安装 PyInstaller(如缺)并打包
|
||||
@@ -35,12 +35,21 @@ def ensure_pyinstaller():
|
||||
|
||||
|
||||
def extra_collect_args():
|
||||
"""CustomTkinter 的主题/字体等数据文件必须显式收集, 否则打包后 GUI 启动报错"""
|
||||
"""Fluent Widgets 的主题资源必须收集; PyInstaller 自带 Qt hooks 会处理 PySide6。"""
|
||||
try:
|
||||
import customtkinter # noqa: F401
|
||||
return ['--collect-all', 'customtkinter']
|
||||
import qfluentwidgets # noqa: F401
|
||||
except ImportError:
|
||||
print('警告: 未安装 customtkinter, 打包产物将无法启动 GUI!')
|
||||
print('警告: 未安装 PySide6-Fluent-Widgets, 请先执行 pip install PySide6-Fluent-Widgets')
|
||||
return []
|
||||
return ['--collect-all', 'qfluentwidgets']
|
||||
|
||||
|
||||
def qt_icu_args():
|
||||
"""Qt6Core imports the unversioned Windows ICU API. PyInstaller can
|
||||
otherwise pick Poppler's ICU 78 from PATH, whose suffixed exports make
|
||||
QtCore.pyd fail with WinError 127 on startup."""
|
||||
if os.name == 'nt' and os.path.exists(r'C:\Windows\System32\icuuc.dll'):
|
||||
return ['--add-binary', r'C:\Windows\System32\icuuc.dll;.']
|
||||
return []
|
||||
|
||||
|
||||
@@ -68,7 +77,7 @@ def build(name, extra_args):
|
||||
cmd = [sys.executable, '-m', 'PyInstaller',
|
||||
'--onefile', '--clean', '--name', name,
|
||||
'--distpath', dist, '--workpath', work, '--specpath', work,
|
||||
] + extra_args + extra_collect_args() + [os.path.join(SRC, 'hdmi_gui.py')]
|
||||
] + extra_args + extra_collect_args() + qt_icu_args() + [os.path.join(SRC, 'hdmi_gui.py')]
|
||||
if os.path.exists(ICO):
|
||||
# exe 文件图标 + 运行时窗口图标资源(从 _MEIPASS 读取)
|
||||
cmd += ['--icon', ICO, '--add-data', f'{ICO};.']
|
||||
@@ -7,7 +7,9 @@ DLPC3421 光机 RS485 控制链路(板上 GD32E230 作 RS485→I2C 网关)
|
||||
响应语义/错误码/就绪门禁与实测帧示例):
|
||||
物理层 RS485 半双工 115200 8N1(PC 经 USB-RS485 转换器接板, DE 由 MCU 硬件换向)
|
||||
命令帧 D5 01 LEN <ASCII命令> CRC 01=板类型, LEN=命令字节数
|
||||
响应帧 B5 TYPE LEN <payload> CRC payload 为 ASCII(ok / err:OL / okTL:246 ...)
|
||||
响应帧 B5 TYPE LEN <payload> CRC payload 为 ASCII(ok / err:OL / okTL:246 ...),
|
||||
例外: M303/M304/M305 载荷为大端二进制
|
||||
(SQ52205 电源实测, 见 POWER_COMMANDS)
|
||||
CRC 帧内第 2 字节(板类型)起累加到最后一个 payload 字节, 取低 8 位
|
||||
帧长 最小 6 字节, 最大 32 字节
|
||||
就绪门禁: DLPC3421 启动完成前 M730/M731/M732 被拒(回 0xFF), 属预期。
|
||||
@@ -81,6 +83,53 @@ PRESETS = (
|
||||
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):
|
||||
"""帧内第 2 字节(板类型)起累加到末 payload 字节, 取低 8 位(不含包头与 CRC)"""
|
||||
return sum(data) & 0xFF
|
||||
@@ -136,15 +185,29 @@ class FrameParser:
|
||||
del self._buf[:1]
|
||||
|
||||
|
||||
def _clean(s):
|
||||
"""控制字符 -> '·': 二进制载荷按 ASCII 解出的 NUL 等会污染日志行,
|
||||
且 Windows 剪贴板遇 NUL 直接截断复制内容"""
|
||||
return ''.join(c if ord(c) >= 0x20 and ord(c) != 0x7F else '·' for c in s)
|
||||
|
||||
|
||||
def describe(fr):
|
||||
"""响应帧 -> 人读文本: TYPE 译码 + payload, err:N 追加 DLPC 错误释义"""
|
||||
"""响应帧 -> 人读文本: TYPE 译码 + payload, err:N 追加 DLPC 错误释义。
|
||||
|
||||
二进制载荷(M303/M304/M305 电源实测)不以 ASCII 展示 —— 改显 hex,
|
||||
避免乱码与 NUL 截断剪贴板(值由 GUI 读数框/CLI format_power 译码)。"""
|
||||
pl = fr.payload
|
||||
if any(ord(c) < 0x20 or ord(c) == 0x7F for c in pl):
|
||||
pl = f'[二进制载荷 {fr.raw[3:-1].hex(" ").upper()}]'
|
||||
else:
|
||||
pl = _clean(pl)
|
||||
if fr.type_code == 0xF0:
|
||||
text = fr.payload or 'ok'
|
||||
text = pl or 'ok'
|
||||
else:
|
||||
text = TYPE_NAMES.get(fr.type_code, f'TYPE=0x{fr.type_code:02X}')
|
||||
if fr.payload:
|
||||
text += f' {fr.payload}'
|
||||
m = re.search(r'err:(\d+)', fr.payload)
|
||||
if pl:
|
||||
text += f' {pl}'
|
||||
m = re.search(r'err:(\d+)', pl)
|
||||
if m:
|
||||
text += f'({DLPC_ERR_NAMES.get(int(m.group(1)), "未知错误码")})'
|
||||
return text
|
||||
@@ -158,8 +221,8 @@ class DLPC485:
|
||||
回调在后台线程触发, GUI 侧需自行投递回主线程(见 hdmi_gui 的轮询队列)。
|
||||
"""
|
||||
|
||||
def __init__(self, on_event, on_status):
|
||||
self.on_event, self.on_status = on_event, on_status
|
||||
def __init__(self, on_event, on_status, on_frame=None):
|
||||
self.on_event, self.on_status, self.on_frame = on_event, on_status, on_frame
|
||||
self.q = queue.Queue()
|
||||
self._target = None # 想态 (port, baud, bytesize, parity, stopbits)
|
||||
self._open_key = None # 当前已打开串口的参数元组
|
||||
@@ -252,7 +315,9 @@ class DLPC485:
|
||||
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)
|
||||
timeout=0.15,
|
||||
write_timeout=1.0) # 写超时: 转换器停滞时 write 会
|
||||
# 永久阻塞并卡死线程, 必须设上限
|
||||
self.parser = FrameParser()
|
||||
self._open_key = target
|
||||
self.on_status(f'已连接 {port} @ {baud} '
|
||||
@@ -261,7 +326,11 @@ class DLPC485:
|
||||
self.ser = None
|
||||
with self._lock:
|
||||
self._target = None
|
||||
self.on_status(f'连接失败: {e}')
|
||||
et = str(e)
|
||||
if 'PermissionError' in repr(e) or '拒绝访问' in et \
|
||||
or 'Access is denied' in et:
|
||||
et += '(串口被占用: 检查串口助手/另一个本工具实例)'
|
||||
self.on_status(f'连接失败: {et}')
|
||||
if self.ser is not None:
|
||||
try:
|
||||
item = None
|
||||
@@ -276,10 +345,17 @@ class DLPC485:
|
||||
data = self.ser.read(256)
|
||||
if 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)} '
|
||||
f'[{fr.raw.hex(" ").upper()}]')
|
||||
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('串口丢失(设备拔出?), 请重新连接')
|
||||
@@ -5,7 +5,7 @@
|
||||
================================================================
|
||||
配合 demo 板当前 EDID 首选时序 800x600@60, 全屏纯色输出。
|
||||
用法: python gen_solid_colors.py → 输出到 ../assets/solid_*.png
|
||||
在 HDMI-Tool 中通过自定义栏位(⚙ 换图)加载, 发布包随附。
|
||||
在 DLPilot 中通过自定义栏位(⚙ 换图)加载, 发布包随附。
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -6,7 +6,7 @@ HDMI EDID 查看器 + 测试图案投屏 + 光机控制 — 入口与命令行
|
||||
配合 LT8619C(HDMI 转 RGB888)→ DLPC3421 转接板调试使用。
|
||||
|
||||
模块划分(重构自原单文件):
|
||||
app_gui.py CustomTkinter 主界面(双栏仪表盘)
|
||||
app_fluent.py PySide6-Fluent-Widgets 主界面(双栏仪表盘)
|
||||
display_win.py Win32 显示器枚举/分辨率/拓扑
|
||||
edid_parse.py EDID 1.4 / CEA-861 解析
|
||||
patterns.py 测试图案全屏投屏窗口
|
||||
@@ -45,9 +45,9 @@ from patterns import PATTERN_NAMES, show_pattern, show_pattern_image
|
||||
|
||||
|
||||
def run_gui(auto_close=0):
|
||||
"""启动 CustomTkinter 主界面(实现在 app_gui.py)"""
|
||||
import app_gui
|
||||
app_gui.run_gui(auto_close)
|
||||
"""启动 PySide6-Fluent-Widgets 主界面(实现在 app_fluent.py)"""
|
||||
import app_fluent
|
||||
app_fluent.run_gui(auto_close)
|
||||
|
||||
|
||||
def run_dump(edid_fields=False):
|
||||
@@ -121,7 +121,8 @@ def run_dlpc(port, baud, cmds):
|
||||
print('← (35s 超时无响应)')
|
||||
rc = 1
|
||||
else:
|
||||
print(f'← {dlpc_rs485.describe(got)} [{got.raw.hex(" ").upper()}]')
|
||||
nice = dlpc_rs485.format_power(cmd, got) # 电源命令二进制载荷译码
|
||||
print(f'← {nice or dlpc_rs485.describe(got)} [{got.raw.hex(" ").upper()}]')
|
||||
if got.type_code != 0xF0:
|
||||
rc = 1
|
||||
ser.close()
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 8.9 KiB |
@@ -9,6 +9,19 @@
|
||||
|
||||
from config import DEFAULT_AREA, HAS_PIL, Image, ImageTk, PAT_W, PAT_H
|
||||
|
||||
# 当前打开的投屏窗口集合: 支持"新图案替换旧图案"与 close_all() 一键恢复桌面
|
||||
_OPEN = set()
|
||||
|
||||
|
||||
def close_all():
|
||||
"""关闭所有打开的投屏窗口, 恢复桌面"""
|
||||
for top in list(_OPEN):
|
||||
try:
|
||||
top.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
_OPEN.clear()
|
||||
|
||||
|
||||
PATTERN_NAMES = {'Red': '纯红', 'Green': '纯绿', 'Blue': '纯蓝',
|
||||
'Checker': '黑白棋盘格', 'Grid': '网格线'}
|
||||
@@ -43,8 +56,9 @@ def _area_box(spec, w, h):
|
||||
def _open_fullscreen(parent, rect, auto_close=0):
|
||||
"""创建覆盖 rect(x,y,w,h) 的无边框全屏黑底窗口, 返回 (top, cv)
|
||||
|
||||
只支持 ESC 退出: overrideredirect 窗口必须 focus_force 强取键盘焦点,
|
||||
否则收不到按键(Windows 上该组合有效)。
|
||||
ESC 退出(需窗口持有键盘焦点); overrideredirect 窗口必须 focus_force
|
||||
强取键盘焦点, 否则收不到按键(Windows 上该组合有效)。
|
||||
窗口登记进 _OPEN: 打开新投屏前会关旧的(替换语义), close_all() 一键全关。
|
||||
"""
|
||||
import tkinter as tk
|
||||
rx, ry, rw, rh = rect
|
||||
@@ -58,9 +72,16 @@ def _open_fullscreen(parent, rect, auto_close=0):
|
||||
cv.pack(fill='both', expand=True)
|
||||
|
||||
def close(_evt=None):
|
||||
_OPEN.discard(top)
|
||||
top.destroy()
|
||||
|
||||
def _on_destroy(e):
|
||||
if e.widget is top: # 子控件销毁也冒泡到此, 只认窗口本身
|
||||
_OPEN.discard(top)
|
||||
|
||||
top.bind('<Escape>', close)
|
||||
top.bind('<Destroy>', _on_destroy)
|
||||
_OPEN.add(top)
|
||||
if auto_close > 0:
|
||||
top.after(auto_close * 1000, close)
|
||||
top.focus_force()
|
||||
@@ -68,7 +89,9 @@ def _open_fullscreen(parent, rect, auto_close=0):
|
||||
|
||||
|
||||
def show_pattern(parent, rect, kind, auto_close=0, area=DEFAULT_AREA):
|
||||
"""在 rect 指定的屏幕上全屏出居中测试卡(无任何 OSD 修饰), 区域由 area 指定"""
|
||||
"""在 rect 指定的屏幕上全屏出居中测试卡(无任何 OSD 修饰), 区域由 area 指定。
|
||||
打开前先关闭已有投屏窗口(替换语义, 不会多层叠罗汉)。"""
|
||||
close_all()
|
||||
top, cv = _open_fullscreen(parent, rect, auto_close)
|
||||
|
||||
def draw(_evt=None):
|
||||
@@ -107,7 +130,9 @@ def show_pattern(parent, rect, kind, auto_close=0, area=DEFAULT_AREA):
|
||||
def show_pattern_image(parent, rect, path, auto_close=0, area=DEFAULT_AREA):
|
||||
"""全屏黑底, 图片 1:1 居中于 area 指定的测试卡区域:
|
||||
小于区域 -> 真实像素居中, 四周黑边;
|
||||
大于区域 -> 等效从中心截取(区域外用黑矩形遮挡)"""
|
||||
大于区域 -> 等效从中心截取(区域外用黑矩形遮挡)。
|
||||
打开前先关闭已有投屏窗口(替换语义)。"""
|
||||
close_all()
|
||||
top, cv = _open_fullscreen(parent, rect, auto_close)
|
||||
|
||||
def draw(_evt=None):
|
||||
@@ -23,6 +23,7 @@ import re
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
@@ -59,7 +60,10 @@ class Api:
|
||||
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):
|
||||
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:
|
||||
@@ -68,6 +72,10 @@ class Api:
|
||||
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)
|
||||
@@ -73,5 +73,35 @@ for _name, cmd, _note in d.PRESETS:
|
||||
d.build_frame(cmd)
|
||||
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')
|
||||
sys.exit(1 if fails else 0)
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"area": "640x360",
|
||||
"slots": [
|
||||
{
|
||||
"name": "西门子星",
|
||||
"file": "bw_star.png"
|
||||
},
|
||||
{
|
||||
"name": "BW_ZONE",
|
||||
"file": "bw_zoneplate.png"
|
||||
},
|
||||
{
|
||||
"name": "BW_TEST",
|
||||
"file": "bw_testcard.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,7 +12,7 @@ PORT = sys.argv[1] if len(sys.argv) > 1 else 'COM9'
|
||||
FULL = len(sys.argv) > 2 and sys.argv[2].lower() == 'full'
|
||||
|
||||
BANK, REG = '60', '6E'
|
||||
STEPS = [ # (写值, 含义, 写入后 HDMI-Tool 应投的图, LA 预期变化 vs 基线)
|
||||
STEPS = [ # (写值, 含义, 写入后 DLPilot 应投的图, LA 预期变化 vs 基线)
|
||||
('00', '关闭交换(基准)', 'solid_0000AA / solid_000055',
|
||||
'D0:AA=13%/55=84%, D1:AA=72%/55=0.9% (基线)'),
|
||||
('80', 'B通道镜像', 'solid_0000AA',
|
||||
|
||||
@@ -14,7 +14,7 @@ LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套上位机工
|
||||
|
||||
- 发布版 zip 开箱即用(Windows 10/11 x64,无需安装 Python)
|
||||
- 源码运行: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`
|
||||
- 重新打包:`DLPilot/src/build_exe.py`(自动安装 PyInstaller);I2C-Inject 目录内 `python build_exe.py`
|
||||
|
||||
@@ -50,4 +50,4 @@ python publish_gitea.py DLPilot-v<版本>-win64.zip --insecure # 上传 Gitea
|
||||
- 源码与打包产物(exe、pattern_slots.json)均入库;发布 zip 不入库,只上传 Gitea Release
|
||||
- 两件工具的寄存器语义(0x606E 位交换 / 0x606D 整组顺序)均经 demo 板逻辑分析仪
|
||||
双色交叉实测,过程数据与规程存于上游主仓库 `doc/architecture/`
|
||||
- 更新:2026-09-11
|
||||
- 更新:2026-09-24
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# agent.md — 仓库交接档案(供后续 Agent 接手)
|
||||
|
||||
> **成文方式**:2026-09-20 由一次会话从 git 全部 40 次提交(6243b8c…e354423)、
|
||||
> `DLPilot/AGENTS.md`、各级 README、`.zcode/plans/` 计划文件还原整理;不含任何口头
|
||||
> 讨论内容,一切以仓库工件为准。
|
||||
>
|
||||
> **分工**:本文管"宏观脉络、决策史、待办";工程约定/不变量/构建规程在
|
||||
> `DLPilot/AGENTS.md`(**改代码前必读**);使用说明在根与各工具 README。
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
- 本仓库是 **LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板**的调试上位机工具集,
|
||||
服务上游固件仓 `hulk/LT8619C_DLPC3421_HDMI`(本机路径
|
||||
`E:\Hulk-Coding\LT8619C_DLPC3421_HDMI`,同 Gitea 实例 gitea.hulk.wang)。
|
||||
光机 RS485 协议**唯一权威**是上游仓 `protocols.md`,本仓不维护协议副本。
|
||||
- 两个工具:
|
||||
- **DLPilot**(原 HDMI-Tool)="3D 光固化光机调试台":HDMI 源端(EDID/分辨率/
|
||||
测试图案投屏)+ 光机控制(RS485→板上 GD32E230 网关→DLPC3421)+ 电源检查
|
||||
(PD 自检/FPDO 挡位判定/功率检测)。
|
||||
- **I2C-Inject**:RP2040 固件 + PC GUI,在 demo 板 I2C 总线上择机注入读写 LT8619C
|
||||
寄存器(避让主控 500ms 轮询,写后读回校验)。
|
||||
|
||||
## 2. 当前状态快照(2026-09-24,v0.4.0)
|
||||
|
||||
- 当前入口为 `DLPilot/src/app_fluent.py`,`APP_VERSION = "0.4.0"`;使用
|
||||
PySide6-Fluent-Widgets。旧 `app_gui.py` 保留作历史对照,不再由入口加载。
|
||||
- 本次发布为 `DLPilot-v0.4.0`,源码、文档与 `release/DLPilot.exe` 合并为一个提交;
|
||||
zip 只上传 Gitea Release,不入库。远端完成状态以标签和附件查询为准。
|
||||
- 支持的模式、EDID 信息默认折叠;修复标题栏图标与 PD 成功状态绿色显示。
|
||||
- Light 显式同步 Qt 调色板、容器背景与 Windows 标题栏,浅灰主背景、白色卡片、深色文字;
|
||||
保留 Dark/System 切换。主题不写入用户的 `pattern_slots.json`。
|
||||
- Qt 单文件打包收集 `qfluentwidgets` 资源与系统 `icuuc.dll`,启动注册并预加载
|
||||
Qt/Shiboken DLL,避免 QtCore 导入失败。
|
||||
- 已完成源码编译、无硬件 RS485 自测(ALL PASS)、Qt 离屏浅色渲染检查和正式 EXE
|
||||
定时启动退出(exit=0)。本次原生桌面自动化接口不可用,未完成成品 Light 截图;
|
||||
不应将离屏截图等同于成品完整视觉或硬件验收。
|
||||
- 本机 `release/pattern_slots.json` 的 COM5 运行配置与 `EDID-viewer/` 未跟踪目录
|
||||
不属于本次发布;保留本地文件,提交及发布使用仓库预置配置。
|
||||
|
||||
## 3. 开发方向主线(时间线)
|
||||
|
||||
**阶段 0 · 寄存器语义实测期(09-03)**
|
||||
I2C-Inject + HDMI-Tool 首版落库。核心成果:0x606E 位交换 / 0x606D 整组顺序两个
|
||||
寄存器语义经 demo 板逻辑分析仪双色交叉实测确立(LA 波形判读脚本
|
||||
`la_wave_analyzer*.py` 即为该过程产物,配套 I2C-Inject 固件内置总线嗅探)。
|
||||
|
||||
**阶段 1 · 工程化与发布流水线(09-10)**
|
||||
确立三区目录(src 人写 / assets 可再生 / release 交付)+ 双文档(README 给人 /
|
||||
AGENTS.md 给 Agent);打通 build_exe → package_release → publish_gitea 流水线,
|
||||
发布 HDMI-Tool v0.1.0。同期收录启动强制扩展显示拓扑与波形/注入脚本。
|
||||
|
||||
**阶段 2 · 光机 RS485 控制集成(09-11,v0.2.0)**
|
||||
新增 `dlpc_rs485.py`:帧构造 `D5 01 LEN <ASCII> CRC` / 响应 `B5 TYPE LEN <payload> CRC`
|
||||
(累加和校验)、增量解析、译码表、DLPC485 链路类(daemon 线程+队列+回调,
|
||||
模式照搬 I2C-Inject 的 Injector)。启动拓扑从"无条件扩展"改为**智能判定**。
|
||||
串口下拉显示设备描述(CH344 多口可辨)。计划文件 `.zcode/plans/plan-sess_a1d8*.md`
|
||||
即此阶段方案,已完整执行。
|
||||
|
||||
**阶段 3 · GUI 重构与产品化(09-12,v0.3.0)**
|
||||
feature/ctk-ui 分支完成 CustomTkinter 双栏仪表盘重构(规格:`UI参考布局.svg`);
|
||||
产品更名 **HDMI-Tool → DLPilot**(3D 光固化光机调试台);光机串口参数化(默认
|
||||
8-N-1)+ TEC 控制(M7/M8)与温度实时显示;DLP/Pilot 双行图标。
|
||||
|
||||
**阶段 4 · 体验打磨(09-14,v0.3.1/0.3.2/0.3.3)**
|
||||
投屏退出语义两轮更名("恢复桌面"→"停止投屏"→"停止测试图像");标题栏版本号;
|
||||
清理残余 HDMI-Tool 引用与目录更名;修复 USB 拔出后同参数重连失效(掉线分支
|
||||
清 `_open_key`)。
|
||||
|
||||
**阶段 5 · 电源检查卡(09-17~18,v0.3.4~0.3.6)**
|
||||
- **电源自检**:M301(协议)→M302(挡位电流)→M303(实测电压) 三步帧驱动状态机,
|
||||
FPDO 判定=电压就近 PD 固定挡(5/9/12/15/20/28V ±5%)+ 挡位电流;12V 且 ≥3A
|
||||
绿底,挡位不符红底,无匹配 ERR(CH224Q 电压配置寄存器只写不可读,只能实测判定)。
|
||||
- **功率检测**:M303+M304+M305 三步序列 → 电压 V / 电流 mA / 功率 W(两位小数)。
|
||||
- **M30x 载荷是二进制例外**(SQ52205 实测):大端原始值非 ASCII,必须走
|
||||
`payload_bytes()`;M303=u16 mV、M304=s16 补码 mA、M305=u32 mW(LEN=4)。
|
||||
封装在 `payload_u16_be/payload_s16_be/payload_u32_be/format_power`。
|
||||
- 伴随一串稳定性修复(详见 §4)。
|
||||
|
||||
**阶段 6 · Fluent UI 重写(09-24,v0.4.0)**
|
||||
PySide6-Fluent-Widgets 替换 CustomTkinter 主界面,延续 EDID、测试图案、RS485、
|
||||
PD/功率/温度/电流及自定义槽位功能;GUI 图案窗口使用 Qt,CLI 图案仍走 tkinter。
|
||||
补齐模式/EDID 折叠、PD 成功状态颜色、图标、Light 调色板与 Qt 单文件 DLL 加载。
|
||||
|
||||
## 4. 尝试过 / 推翻过 / 反复调过的思路(勿重蹈)
|
||||
|
||||
| # | 问题 | 最初做法 → 最终做法 / 教训 |
|
||||
|---|---|---|
|
||||
| 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 | 窗口最大化卡顿 | EDID 信息区 CTkScrollableFrame+40 余个逐行 Label,最大化时逐控件重绘 → 换 CTkTextbox 整体重排,开销低一个量级(30bc224) |
|
||||
| 6 | 启动强制扩展拓扑 | 无条件 `SetDisplayConfig` 扩展会打扰用户自选拓扑(如家里"仅第二屏幕")→ 仅当未激活显示器 EDID 厂商字节为转接板签名 `61 93`("XLS") 时才拉回(d9ecff4) |
|
||||
| 7 | GUI 框架 | 原生 tkinter → CustomTkinter 双栏仪表盘(84838e3);v0.4.0 再迁移到 PySide6-Fluent-Widgets;旧 CTk 文件仅供历史对照 |
|
||||
| 8 | 投屏退出按钮文案 | "恢复桌面"→"停止投屏"→"停止测试图像"(3f35ad2→b167ce3):最终语义是关测试图窗口,与显示拓扑恢复无关 |
|
||||
| 9 | USB 拔出后重连失效 | 掉线分支未清 `_open_key`,同参数重连被"参数未变"逻辑跳过 → 掉线同步清缓存(51e2f1c) |
|
||||
| 10 | 电源自检卡死 | 帧路由引用未定义变量 `payload`(3f36ead),改名/重构时注意帧回调的作用域 |
|
||||
| 11 | Gitea 发布 | python urllib 访问该实例间歇 `SSL: UNEXPECTED_EOF` → 必须 `--insecure`;`GET /releases/tags/{tag}` 的 assets 是纯数组而 `GET /releases/{id}` 是 `{items:[...]}`,publish_gitea.py 已兼容两种形状,勿"简化";请求自动重试已加(f0f8073) |
|
||||
|
||||
## 5. 未实操 / 待办清单(接手者从这里挑活)
|
||||
|
||||
### 5.1 v0.4.0 发布交接
|
||||
v0.4.0 已覆盖未单独发布的 v0.3.6 功能与本次 Fluent UI 改动,无需另发 v0.3.6。
|
||||
发布时先推送代码与同提交标签,再上传 `DLPilot-v0.4.0-win64.zip`;
|
||||
核对远端标签提交、附件名称/大小,并记录验证结果。若需补传,先查询资产,避免重复上传。
|
||||
|
||||
### 5.2 【待硬件】0x606E 的 0x40/0x80 两组位交换
|
||||
demo 板仅实测了 bit5(0x20)=镜像 D[7:0];0x40/0x80 对另外两组**未单独实测**,
|
||||
等自板(转接板)点亮时用 I2C-Inject 验证(见 I2C-Inject/README §3)。
|
||||
|
||||
### 5.3 【待素材】校准图案槽位预置为空
|
||||
测试图案卡第二排 5 个槽位 UI 标注"校准图案(齿轮改图)",为 3D 光固化测试/校准图
|
||||
预留;`release/pattern_slots.json` 中这些槽位 `file: null`。拿到校准图素材后:
|
||||
放 assets/ → 生成脚本入库 → 更新预置 slots → 重新打包。
|
||||
|
||||
### 5.4 【待真机帧】M305 回归向量是算法推演帧
|
||||
`selftest_rs485.py` 中 M303/M304 用真机帧,M305 向量是按协议推演的;拿到真机
|
||||
M305 回包后替换向量,使回归更实。
|
||||
|
||||
### 5.5 README 已同步
|
||||
本次已将根 README 与 DLPilot/README 的安装依赖、界面描述和日期更新到 v0.4.0。
|
||||
|
||||
### 5.6 【明确"不做",非待办】
|
||||
- **TI 原始 I2C 命令透传不做**:GD32 网关固件只收 ASCII M 命令集(v0.2.0 计划的
|
||||
显式决策)。
|
||||
- **不引入 pyserial 硬依赖**:保持 try-import + HAS_* 标志 + 缺失降级的可选依赖
|
||||
模式(Pillow 同理)。
|
||||
|
||||
## 6. 快速上手(完整规程见 DLPilot/AGENTS.md §5/§7)
|
||||
|
||||
```powershell
|
||||
cd DLPilot/src
|
||||
python -m compileall -q . # 编译检查
|
||||
python selftest_rs485.py # 光机协议回归(改 dlpc_rs485.py 后必跑)
|
||||
python hdmi_gui.py --seconds 5 # GUI 冒烟
|
||||
python build_exe.py # 打包 → ../release/DLPilot.exe
|
||||
python package_release.py 0.4.0 # 组装当前版本 zip;新版本先更新 APP_VERSION
|
||||
python publish_gitea.py ../release/DLPilot-v0.4.0-win64.zip --replace --insecure
|
||||
```
|
||||
|
||||
上游协议/寄存器语义问题先查上游仓 `protocols.md` 与 `doc/architecture/`;
|
||||
本仓改动约束(目录契约、12 条代码不变量、领域测试值含义)以 `DLPilot/AGENTS.md`
|
||||
为准,**其中领域语义的测试值(55/AA/FF、640x360、800x600@60)有明确用途,勿顺手改**。
|
||||