From b58cdc42b6f54ed0d335536b6ae6496fa42229e5 Mon Sep 17 00:00:00 2001 From: hulk Date: Thu, 24 Sep 2026 18:56:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(DLPilot):=20v0.4.0=20=E2=80=94=20PySide6-F?= =?UTF-8?q?luent-Widgets=20UI=20and=20Light=20theme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DLPilot/AGENTS.md | 56 ++- DLPilot/README.md | 13 +- DLPilot/release/README.md | 13 +- DLPilot/src/app_fluent.py | 869 ++++++++++++++++++++++++++++++++++++++ DLPilot/src/build_exe.py | 23 +- DLPilot/src/hdmi_gui.py | 8 +- README.md | 4 +- agent.md | 57 +-- 8 files changed, 969 insertions(+), 74 deletions(-) create mode 100644 DLPilot/src/app_fluent.py diff --git a/DLPilot/AGENTS.md b/DLPilot/AGENTS.md index 1eea4da..f01a4dc 100644 --- a/DLPilot/AGENTS.md +++ b/DLPilot/AGENTS.md @@ -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-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. 领域语义(测试值都有含义,勿"顺手改") @@ -128,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) ``` 发布约定: @@ -153,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` —— 投图链路; @@ -167,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 提交。 + 发布使用仓库预置配置,临时打包后恢复本机文件,勿将运行态配置悄悄提交。 diff --git a/DLPilot/README.md b/DLPilot/README.md index 905395f..97df143 100644 --- a/DLPilot/README.md +++ b/DLPilot/README.md @@ -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) | @@ -198,7 +198,8 @@ python la_wave_analyzer3.py # 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 # 测试图案全屏投屏窗口 @@ -223,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. 构建与发布 @@ -236,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) diff --git a/DLPilot/release/README.md b/DLPilot/release/README.md index 905395f..97df143 100644 --- a/DLPilot/release/README.md +++ b/DLPilot/release/README.md @@ -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) | @@ -198,7 +198,8 @@ python la_wave_analyzer3.py # 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 # 测试图案全屏投屏窗口 @@ -223,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. 构建与发布 @@ -236,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) diff --git a/DLPilot/src/app_fluent.py b/DLPilot/src/app_fluent.py new file mode 100644 index 0000000..19b3ff5 --- /dev/null +++ b/DLPilot/src/app_fluent.py @@ -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 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() diff --git a/DLPilot/src/build_exe.py b/DLPilot/src/build_exe.py index d3d9ddb..bf8f5d7 100644 --- a/DLPilot/src/build_exe.py +++ b/DLPilot/src/build_exe.py @@ -3,8 +3,8 @@ """ 把 hdmi_gui.py 打包成单文件 exe(工具名: DLPilot) ================================ -使用 PyInstaller --onefile --noconsole: Python 解释器 + 标准库 + tkinter -全部打进一个 exe, 目标机器无需安装任何依赖。 +使用 PyInstaller --onefile --noconsole: Python 解释器、PySide6 和 +PySide6-Fluent-Widgets 全部打进一个 exe, 目标机器无需安装依赖。 用法: python build_exe.py 自动安装 PyInstaller(如缺)并打包 @@ -35,13 +35,22 @@ def ensure_pyinstaller(): def extra_collect_args(): - """CustomTkinter 的主题/字体等数据文件必须显式收集, 否则打包后 GUI 启动报错""" + """Fluent Widgets 的主题资源必须收集; PyInstaller 自带 Qt hooks 会处理 PySide6。""" try: - import customtkinter # noqa: F401 - return ['--collect-all', 'customtkinter'] + import qfluentwidgets # noqa: F401 except ImportError: - print('警告: 未安装 customtkinter, 打包产物将无法启动 GUI!') + print('警告: 未安装 PySide6-Fluent-Widgets, 请先执行 pip install PySide6-Fluent-Widgets') return [] + return ['--collect-all', 'qfluentwidgets'] + + +def qt_icu_args(): + """Qt6Core imports the unversioned Windows ICU API. PyInstaller can + otherwise pick Poppler's ICU 78 from PATH, whose suffixed exports make + QtCore.pyd fail with WinError 127 on startup.""" + if os.name == 'nt' and os.path.exists(r'C:\Windows\System32\icuuc.dll'): + return ['--add-binary', r'C:\Windows\System32\icuuc.dll;.'] + return [] def remove_stale(exe_path): @@ -68,7 +77,7 @@ def build(name, extra_args): cmd = [sys.executable, '-m', 'PyInstaller', '--onefile', '--clean', '--name', name, '--distpath', dist, '--workpath', work, '--specpath', work, - ] + extra_args + extra_collect_args() + [os.path.join(SRC, 'hdmi_gui.py')] + ] + extra_args + extra_collect_args() + qt_icu_args() + [os.path.join(SRC, 'hdmi_gui.py')] if os.path.exists(ICO): # exe 文件图标 + 运行时窗口图标资源(从 _MEIPASS 读取) cmd += ['--icon', ICO, '--add-data', f'{ICO};.'] diff --git a/DLPilot/src/hdmi_gui.py b/DLPilot/src/hdmi_gui.py index 1fd0d85..fbfdacb 100644 --- a/DLPilot/src/hdmi_gui.py +++ b/DLPilot/src/hdmi_gui.py @@ -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): diff --git a/README.md b/README.md index 2eccaa1..95b8506 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/agent.md b/agent.md index 2945f60..f4593bd 100644 --- a/agent.md +++ b/agent.md @@ -20,14 +20,22 @@ - **I2C-Inject**:RP2040 固件 + PC GUI,在 demo 板 I2C 总线上择机注入读写 LT8619C 寄存器(避让主控 500ms 轮询,写后读回校验)。 -## 2. 当前状态快照(2026-09-20) +## 2. 当前状态快照(2026-09-24,v0.4.0) -- `APP_VERSION = '0.3.6'`(`DLPilot/src/app_gui.py:43`);工作树干净; - 本地 main == origin/main(e354423),**全部提交已推送**。 -- Gitea Releases 最新为 **v0.3.5** —— v0.3.6 已打包、已提交,**但未发布**(见 §5.1)。 -- `release/DLPilot.exe` 与 `release/DLPilot-v0.3.6-win64.zip` 是 2026-09-18 15:32 - 同批构建,**均已包含最后的二进制载荷 hex 日志修复**(e354423),内容一致。 -- `release/pattern_slots.json` 记忆的光机串口为 COM15(115200 8-N-1)。 +- 当前入口为 `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. 开发方向主线(时间线) @@ -58,7 +66,7 @@ feature/ctk-ui 分支完成 CustomTkinter 双栏仪表盘重构(规格:`UI 清理残余 HDMI-Tool 引用与目录更名;修复 USB 拔出后同参数重连失效(掉线分支 清 `_open_key`)。 -**阶段 5 · 电源检查卡(09-17~18,v0.3.4~0.3.6)—— 当前主战场** +**阶段 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 电压配置寄存器只写不可读,只能实测判定)。 @@ -68,6 +76,11 @@ feature/ctk-ui 分支完成 CustomTkinter 双栏仪表盘重构(规格:`UI 封装在 `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. 尝试过 / 推翻过 / 反复调过的思路(勿重蹈) | # | 问题 | 最初做法 → 最终做法 / 教训 | @@ -78,7 +91,7 @@ feature/ctk-ui 分支完成 CustomTkinter 双栏仪表盘重构(规格:`UI | 4 | "串口断不开" | 表象是断开逻辑 bug,实际根源是**双实例互抢串口** → 单实例锁 + 串口占用提示(e87bf14) | | 5 | 窗口最大化卡顿 | EDID 信息区 CTkScrollableFrame+40 余个逐行 Label,最大化时逐控件重绘 → 换 CTkTextbox 整体重排,开销低一个量级(30bc224) | | 6 | 启动强制扩展拓扑 | 无条件 `SetDisplayConfig` 扩展会打扰用户自选拓扑(如家里"仅第二屏幕")→ 仅当未激活显示器 EDID 厂商字节为转接板签名 `61 93`("XLS") 时才拉回(d9ecff4) | -| 7 | GUI 框架 | 原生 tkinter → CustomTkinter 双栏仪表盘(84838e3);CTk 6.x 怪癖(ComboBox 无 textvariable、tag_config 不可用、grid/pack 不可混用)已兜底,见 AGENTS.md §6 | +| 7 | GUI 框架 | 原生 tkinter → CustomTkinter 双栏仪表盘(84838e3);v0.4.0 再迁移到 PySide6-Fluent-Widgets;旧 CTk 文件仅供历史对照 | | 8 | 投屏退出按钮文案 | "恢复桌面"→"停止投屏"→"停止测试图像"(3f35ad2→b167ce3):最终语义是关测试图窗口,与显示拓扑恢复无关 | | 9 | USB 拔出后重连失效 | 掉线分支未清 `_open_key`,同参数重连被"参数未变"逻辑跳过 → 掉线同步清缓存(51e2f1c) | | 10 | 电源自检卡死 | 帧路由引用未定义变量 `payload`(3f36ead),改名/重构时注意帧回调的作用域 | @@ -86,17 +99,10 @@ feature/ctk-ui 分支完成 CustomTkinter 双栏仪表盘重构(规格:`UI ## 5. 未实操 / 待办清单(接手者从这里挑活) -### 5.1 【发布缺口,一条命令的事】v0.3.6 未发布 -代码、exe、zip 均已就绪并推送(§2),但 Gitea 上没有 v0.3.6 Release、也没有 -DLPilot-v0.3.6 tag(本地 tag 止于 v0.3.5)。待执行: - -```powershell -cd DLPilot/src -python publish_gitea.py DLPilot-v0.3.6-win64.zip --replace --insecure # 需 GITEA_TOKEN -``` - -(GITEA_TOKEN 已 setx 为用户级环境变量,新开会话自动继承;Release 经 API 创建时 -远端会自动建 tag。) +### 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 对另外两组**未单独实测**, @@ -111,9 +117,8 @@ demo 板仅实测了 bit5(0x20)=镜像 D[7:0];0x40/0x80 对另外两组**未 `selftest_rs485.py` 中 M303/M304 用真机帧,M305 向量是按协议推演的;拿到真机 M305 回包后替换向量,使回归更实。 -### 5.5 【小】README 日期戳滞后 -根 README 与 DLPilot/README 的"更新:2026-09-11"停在旧日期,但正文内容其实已 -同步到 M305/电源检查;下次改动时顺手刷新即可。 +### 5.5 README 已同步 +本次已将根 README 与 DLPilot/README 的安装依赖、界面描述和日期更新到 v0.4.0。 ### 5.6 【明确"不做",非待办】 - **TI 原始 I2C 命令透传不做**:GD32 网关固件只收 ASCII M 命令集(v0.2.0 计划的 @@ -125,12 +130,12 @@ M305 回包后替换向量,使回归更实。 ```powershell cd DLPilot/src -python -m py_compile *.py # 编译检查 +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.3.7 # 组装 zip(版本号从 0.3.7 起,0.3.6 已占用) -python publish_gitea.py DLPilot-v0.3.7-win64.zip --replace --insecure +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/`;