From 7051aa1774367a2fc18edfb3f606508231674e86 Mon Sep 17 00:00:00 2001 From: hulk Date: Sat, 12 Sep 2026 01:30:36 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(HDMI-Tool):=20CustomTkinter=20?= =?UTF-8?q?=E5=8F=8C=E6=A0=8F=E4=BB=AA=E8=A1=A8=E7=9B=98=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E9=87=8D=E6=9E=84(feature/ctk-ui)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HDMI-Tool/AGENTS.md | 56 +- HDMI-Tool/README.md | 52 +- HDMI-Tool/UI参考布局.svg | 168 ++++ HDMI-Tool/UI布局参考图.png | Bin 0 -> 52547 bytes HDMI-Tool/release/README.md | 6 +- HDMI-Tool/src/app_gui.py | 695 +++++++++++++++ HDMI-Tool/src/build_exe.py | 12 +- HDMI-Tool/src/config.py | 100 +++ HDMI-Tool/src/display_win.py | 253 ++++++ HDMI-Tool/src/edid_parse.py | 548 ++++++++++++ HDMI-Tool/src/hdmi_gui.py | 1589 +--------------------------------- HDMI-Tool/src/patterns.py | 131 +++ README.md | 3 +- 13 files changed, 1999 insertions(+), 1614 deletions(-) create mode 100644 HDMI-Tool/UI参考布局.svg create mode 100644 HDMI-Tool/UI布局参考图.png create mode 100644 HDMI-Tool/src/app_gui.py create mode 100644 HDMI-Tool/src/config.py create mode 100644 HDMI-Tool/src/display_win.py create mode 100644 HDMI-Tool/src/edid_parse.py create mode 100644 HDMI-Tool/src/patterns.py diff --git a/HDMI-Tool/AGENTS.md b/HDMI-Tool/AGENTS.md index 8d9cf72..f5bb745 100644 --- a/HDMI-Tool/AGENTS.md +++ b/HDMI-Tool/AGENTS.md @@ -34,10 +34,15 @@ HDMI-Tool/ | 文件 | 角色 | |---|---| -| `src/hdmi_gui.py` | 单文件主程序:GUI(tkinter) + 命令行 + 光机控制区, 纯标准库 + 可选依赖 | +| `src/hdmi_gui.py` | 入口: argparse 命令行分发(--dump/--edid-fields/--test-pattern/--dlpc-cmd/--seconds), GUI 委托 app_gui | +| `src/app_gui.py` | CustomTkinter 主界面: 双栏仪表盘(左 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/HDMI-Tool.exe`(onefile/noconsole,内嵌图标+pyserial) | +| `src/build_exe.py` | PyInstaller 打包 → `release/HDMI-Tool.exe`(onefile/noconsole, 内嵌图标+pyserial+customtkinter) | | `src/package_release.py` | 复制图案到 release/(平铺) → `release/HDMI-Tool-v-win64.zip`(校验栏位引用完整性; release/ 与 zip 内容一致) | | `src/publish_gitea.py` | 建/复用 Gitea Release 并上传 zip(tag 默认取 zip 文件名) | | `src/hdmi_source.ps1/.bat` | 独立 PowerShell 命令行工具(自包含,与 GUI 无代码共享) | @@ -46,17 +51,21 @@ HDMI-Tool/ | `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 双栏布局的规格来源, 随分支入库) | ## 3. 代码不变量(改 hdmi_gui.py 必须维持) -1. **纯标准库 + 可选依赖**:hdmi_gui.py 核心只准用标准库(ctypes/winreg/tkinter/…)。 - 可选依赖一律走"try-import + HAS_* 标志 + 缺失降级"模式:Pillow(自定义图片 JPG/BMP/WebP)、 - pyserial(光机控制)。缺失时对应功能禁用/提示安装,其余功能必须照常工作。 -2. **pyserial 只准在 dlpc_rs485.py 里 import**:hdmi_gui.py 通过 `dlpc_rs485.HAS_SERIAL` - 与 `DLPC485` 类间接使用,不得直接 import serial(协议与串口细节收敛在单一模块)。 -3. **BASE_DIR 语义**:frozen(PyInstaller onefile)时 `BASE_DIR=exe 所在目录`(`sys.executable`), - 源码运行时 `=hdmi_gui.py 所在目录`(`__file__`)。所有用户数据(pattern_slots.json、 - 自定义图片)都写/读自 BASE_DIR **平铺**存放,不建子目录。 +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` 类间接使用)。 +3. **BASE_DIR 语义**(定义在 config.py):frozen(PyInstaller onefile)时 + `BASE_DIR=exe 所在目录`(`sys.executable`),源码运行时 `=config.py 所在目录` + (即 src/)。所有用户数据(pattern_slots.json、自定义图片)都写/读自 BASE_DIR + **平铺**存放,不建子目录。 4. **自定义栏位 `file` 字段是裸文件名**:加载时 `os.path.join(BASE_DIR, file)` 解析, 因此 pattern_slots.json 引用的图片必须与 exe 同目录 —— package_release.py 正是按此平铺打包。 5. **exe 自包含**:图标经 `--add-data` 内嵌(运行时从 `_MEIPASS` 读);内置五种图案 @@ -76,6 +85,10 @@ HDMI-Tool/ `App.destroy()`,在那里 `dlpc.close()` 收线程。 11. **pattern_slots.json 格式**:`{'area', 'slots', 'serial'}` 三键共存;load 兼容旧版 纯数组(无 area)与无 serial 键两种历史格式,save 永远写全三键。 +12. **GUI = app_gui.py 双栏仪表盘**(规格:`HDMI-Tool/UI参考布局.svg`):左列 HDMI 源端 + 五卡(显示器/模式/EDID 信息/测试图案/hex),右列光机四卡(串口/数据/命令/滑条), + 顶栏 Dark/Light/System 切换(ttk.Treeview 颜色随动)。打包必须 `--collect-all + customtkinter`(build_exe.py 已内置,勿删)——CTk 主题/字体数据缺失会让 exe 启动即崩。 ## 4. 领域语义(测试值都有含义,勿"顺手改") @@ -117,21 +130,24 @@ python publish_gitea.py HDMI-Tool-v<版本>-win64.zip --replace --insecure # ## 6. 已知坑(历史踩过,勿重蹈) -- onefile 运行时 `__file__` 指向临时解压目录 `_MEIPASS`,定位 exe 必须用 `sys.executable`(§3.2); +- onefile 运行时 `__file__` 指向临时解压目录 `_MEIPASS`,定位 exe 必须用 `sys.executable`(§3.3); - 重新打包前旧 exe 可能被残留进程占用 → build_exe.py 会 taskkill 同名进程,手改打包脚本时保留; - 打包后本机图标缓存不刷新:`ie4uinit -show`,不是产物问题; - 改图标流程:改 svg → `make_ico.py` 生成 ico → 重新打包(ico 同时是文件图标与运行时窗口图标); - GUI 与 hdmi_source.ps1 各自独立实现显示器枚举,行为可能微差(GUI 与 `-List` 序号一致, 但与"设置"面板序号无保证);多屏操作前先确认序号; -- `pattern_slots.json` 兼容两种格式:新 `{area, slots[]}`,旧纯数组(无 area)——load_slots 已兼容, - 但写出只应写新格式。 +- `pattern_slots.json` 兼容两种历史格式,写出只写新格式(详见 §3.11); +- CustomTkinter 6.x:`CTkComboBox` 不支持 `textvariable`(用 `.set()/.get()`);`CTkTextbox` + 的 `tag_config` 在部分版本不可用,app_gui 已 try/except 兜底为单色;同一容器内 + grid/pack 不可混用(卡片用 grid 挂进列,卡片内部一律 pack——`_card` 返回 (卡片, 内容)); ## 7. 验证清单(改完代码后) -1. `python selftest_rs485.py` —— 光机协议回归(改 dlpc_rs485.py 后必跑,向量来自上游 protocols.md); -2. `python hdmi_gui.py --seconds 5` —— GUI 冒烟(自动开关); -3. `python hdmi_gui.py --test-pattern Checker --area 800x600 --seconds 3` —— 投图链路; -4. `python hdmi_gui.py --dump` —— EDID 解算不抛异常; -5. `python hdmi_gui.py --dlpc-cmd M999`(不带 --port)—— 应明确报错而非崩溃(无硬件时的优雅降级); -6. `python build_exe.py` → 双击 release/HDMI-Tool.exe 确认图标/启动/投图/光机区渲染; -7. `python package_release.py <版本>` —— 不报"引用图片缺失"即栏位完整性 OK。 +1. `python -m py_compile src/*.py` —— 全模块可编译; +2. `python selftest_rs485.py` —— 光机协议回归(改 dlpc_rs485.py 后必跑,向量来自上游 protocols.md); +3. `python hdmi_gui.py --seconds 5` —— GUI 冒烟(自动开关); +4. `python hdmi_gui.py --test-pattern Checker --area 800x600 --seconds 3` —— 投图链路; +5. `python hdmi_gui.py --dump` —— EDID 解算不抛异常; +6. `python hdmi_gui.py --dlpc-cmd M999`(不带 --port)—— 应明确报错而非崩溃(无硬件时的优雅降级); +7. `python build_exe.py` → 双击 release/HDMI-Tool.exe 确认图标/双栏布局/投图/光机区渲染; +8. `python package_release.py <版本>` —— 不报"引用图片缺失"即栏位完整性 OK。 diff --git a/HDMI-Tool/README.md b/HDMI-Tool/README.md index d56f61b..602ea1a 100644 --- a/HDMI-Tool/README.md +++ b/HDMI-Tool/README.md @@ -20,7 +20,8 @@ | EDID 逐字节解析 | "EDID 原始数据"框右上"逐字节解析"按钮:按偏移逐字段展开基础块与 CTA-861 扩展块,每字段附原始字节 hex;命令行等价 `--edid-fields` | | 分辨率切换 | "分辨率"下拉列出显卡按该屏 EDID 提供的全部模式;**应用**=临时切换(不写注册表,重启/恢复即还原),**恢复**=回到本次会话切改前的模式 | | 测试图案投屏 | 全屏投到所选显示器,测试卡区域可选 `640x360 / 800x600 / full 铺满`;内置 纯红/纯绿/纯蓝/黑白棋盘格/网格线 + R/G/B 三行 55/AA/FF 纯色值按钮 + 3 个自定义图片栏位 | -| 光机控制 | RS485 → 板载 GD32E230 网关 → DLPC3421:开关光机(可定时)、设光强、查电流/温度/版本、TEC 控制、软件重启;收发日志带协议译码(见 §5) | +| 界面 | CustomTkinter 深色双栏仪表盘(左列 HDMI 源端 / 右列 光机控制),顶栏可切换 Dark/Light/System | +| 光机控制 | RS485 → 板载 GD32E230 网关 → DLPC3421:开关光机(可定时)、设光强、查电流/温度/版本、TEC 控制、软件重启;光强/定时提供快捷滑条;收发日志带协议译码(见 §5) | | 智能显示拓扑 | 仅当检测到转接板(EDID 厂商 "XLS")接入但未进入桌面(Windows 卡在"仅电脑屏幕")时才强制扩展把它拉回;你主动选的"仅第二屏幕"等拓扑不会被改动 | | 命令行模式 | 全部功能可脚本化调用,供自动化测试(见 §6、§7) | | 波形分析脚本 | 逻辑分析仪 CSV 波形按占空比签名分段,输出行结构/占空比/消隐明细(见 §7.3) | @@ -45,24 +46,25 @@ HDMI-Tool/ ## 3. GUI 使用 -主窗口自上而下四块区域: +主窗口为双栏仪表盘(布局设计稿:`UI参考布局.svg`),默认深色外观,顶栏可切换 Dark/Light/System: ``` -┌ 显示器: [ ... ▼] [刷新] ┐ -├ 分辨率: [800x600@60 ▼] [应用] [恢复] ├ 显示器/EDID -│ 支持的模式(EDID 解算) | EDID 信息 ┘ -├ 测试图案 — 图案按钮 / R,G,B 0x55,0xAA,0xFF / 测试卡区域 / 自定义栏位 ┐ -├ 光机控制 — 串口 [连接] / 开关光机 / 光强 / 命令下拉 / 自定义命令 ┘ 投屏+光机 -│ └ 收发日志(带协议译码与原始 hex) -└ EDID 原始数据 — hex dump [逐字节解析] ┘ +左列(HDMI 源端) 右列(光机控制 RS485) +┌ 显示器选择区域 [刷新] ┐ ┌ 串口选择 + 波特率 / 连接状态 ┐ +├ 支持的模式: 分辨率[应用][恢复] + 明细 ┐ ├ 串口数据 — 收发日志(译码+hex) ┤ +├ EDID 信息(表格) ┘ ├ 光机命令 — 快捷按钮 │ +├ 测试图案 / 纯色 / 自定义 / 测试卡区域 │ 预设下拉 / 自定义命令输入 │ +├ EDID 原始数据 [逐字节解析] ┘ └ 快捷滑条 — 光强 / 定时投光(秒) ┘ ``` -1. 顶部显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举; -2. **切分辨率**:选模式 → "应用";测完点"恢复"; -3. **投测试图**:先选**测试卡区域**(640x360 / 800x600 / full)→ 点图案按钮全屏投放, - 按 **ESC** 或点击退出;R/G/B 三行纯色按钮(55/AA/FF)用于 RGB 位交换类测试 - (55 与 AA 互为字节镜像对,FF 全高基准);自定义图片点"⚙"配置(图片自动复制到 exe 旁); -4. 区域与自定义栏位配置自动保存在 exe 旁的 `pattern_slots.json`。 +1. 左上显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举; +2. **切分辨率**:"支持的模式"卡片里选模式 → "应用";测完点"恢复"; +3. **投测试图**:"测试图案"卡片先选**测试卡区域**(640x360 / 800x600 / full)→ + 点图案按钮全屏投放,按 **ESC** 或点击退出;R/G/B 纯色按钮(55/AA/FF,按通道着色) + 用于 RGB 位交换类测试;自定义图片点"⚙"配置(图片自动复制到 exe 旁); +4. **光机控制**:右列选串口 → 连接 → 快捷按钮/预设下拉/自定义输入(回车即发); + 快捷滑条直接拖光强后"下发 M731S",拖秒数后"开光机(定时)"(0 秒=不限时 M730S0); +5. 测试卡区域、自定义栏位与串口记忆自动保存在 exe 旁的 `pattern_slots.json`。 ## 4. 典型测试场景 @@ -177,8 +179,13 @@ python la_wave_analyzer3.py # 3 通道(RGB 三组同拍, ch8/ ``` HDMI-Tool/ ├── src/ # 源代码(人工维护区) -│ ├── hdmi_gui.py # 主程序:GUI + 命令行(标准库; pyserial/Pillow 可选) -│ ├── dlpc_rs485.py # 光机 RS485 协议与串口链路(帧/解析/译码/线程) +│ ├── hdmi_gui.py # 入口与命令行模式(argparse 分发) +│ ├── app_gui.py # CustomTkinter 主界面(双栏仪表盘) +│ ├── display_win.py # Win32 显示器枚举/分辨率/智能拓扑(纯标准库) +│ ├── edid_parse.py # EDID 1.4/CEA-861 解析(纯标准库) +│ ├── patterns.py # 测试图案全屏投屏窗口 +│ ├── config.py # 运行目录与 pattern_slots.json 配置 +│ ├── dlpc_rs485.py # 光机 RS485 协议与串口链路 │ ├── selftest_rs485.py # 光机协议自检(无硬件回归) │ ├── build_exe.py # PyInstaller 打包 → release/ │ ├── package_release.py # 组装发布 zip(exe+图案+说明) @@ -197,18 +204,21 @@ HDMI-Tool/ │ ├── *.png / README.md # 附属文件平铺副本(由 package_release.py 从 assets/ 同步) │ └── *.zip # 发布包(不入库, 上传 Gitea Release) ├── README.md # 本说明 -└── AGENTS.md # 面向 AI/Agent 的工程档案(结构/约定/构建规程) +├── AGENTS.md # 面向 AI/Agent 的工程档案(结构/约定/构建规程) +└── UI参考布局.svg/.png # 界面布局设计稿(app_gui.py 按此实现) ``` -从源码运行:`python src/hdmi_gui.py`,需 Python ≥3.10,标准库即可跑 EDID/投屏; -光机控制需 pyserial(`py -m pip install pyserial`),自定义图片要 JPG/BMP/WebP 需可选 Pillow。 +从源码运行:`python src/hdmi_gui.py`,需 Python ≥3.10;EDID/命令行纯标准库; +GUI 需 customtkinter(`py -m pip install customtkinter`);光机控制需 pyserial; +自定义图片要 JPG/BMP/WebP 需可选 Pillow。 ## 9. 构建与发布 ```powershell cd src python selftest_rs485.py # 光机协议回归(改 dlpc_rs485.py 后必跑) -python build_exe.py # 打包 → ../release/HDMI-Tool.exe(自动装 PyInstaller; 内置 pyserial) +python build_exe.py # 打包 → ../release/HDMI-Tool.exe(自动装 PyInstaller; + # 内置 pyserial/customtkinter, CTk 主题数据自动收集) python build_exe.py --debug # 额外出一个带控制台的调试变体(--dump 可见输出) python package_release.py 0.2.0 # 组装 release/HDMI-Tool-v0.2.0-win64.zip python publish_gitea.py HDMI-Tool-v0.2.0-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN) diff --git a/HDMI-Tool/UI参考布局.svg b/HDMI-Tool/UI参考布局.svg new file mode 100644 index 0000000..05b4212 --- /dev/null +++ b/HDMI-Tool/UI参考布局.svg @@ -0,0 +1,168 @@ + + + + + + + + 显示器选择区域 + + 串口选择区域 + 串口参数选择显示 + 支持的模式 + + + + + + EDID信息 + 测试的图案、纯色、自定义图案等区域 + EDID原始数据 + 串口数据区域 + + 预设按钮、自定义命令亮度、时间等快捷滑条区域 + + diff --git a/HDMI-Tool/UI布局参考图.png b/HDMI-Tool/UI布局参考图.png new file mode 100644 index 0000000000000000000000000000000000000000..b28ded21e15b14eb65beb9359ad9474aff70b7c7 GIT binary patch literal 52547 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 R;vnG5%=j#-^dGl7{{yJG3S0mH literal 0 HcmV?d00001 diff --git a/HDMI-Tool/release/README.md b/HDMI-Tool/release/README.md index a2aa0aa..d56f61b 100644 --- a/HDMI-Tool/release/README.md +++ b/HDMI-Tool/release/README.md @@ -86,8 +86,10 @@ HDMI-Tool/ `protocols.md` 为准:命令帧 `D5 01 LEN CRC`、响应帧 `B5 TYPE LEN CRC`, CRC 为第 2 字节起累加和取低 8 位。 -GUI"光机控制"区:选串口 → 连接 → 点快捷按钮或命令下拉/自定义输入发送;日志区实时显示 -`→ 命令` / `← 译码结果 (原始 hex)`,错误自动附释义(如 `err:3(I2C NACK)`)。 +GUI"光机控制"区:选串口 → 连接 → 点快捷按钮或命令下拉/自定义输入发送;串口下拉显示 +"COMx + 设备描述",CH344 等多路适配器每一路描述不同(如 serial-A/B/C/D),据此选口; +多口描述相同时自动附物理位置消歧。日志区实时显示 `→ 命令` / `← 译码结果 (原始 hex)`, +错误自动附释义(如 `err:3(I2C NACK)`)。 | 命令 | 功能 | 响应示例 | |---|---|---| diff --git a/HDMI-Tool/src/app_gui.py b/HDMI-Tool/src/app_gui.py new file mode 100644 index 0000000..db34500 --- /dev/null +++ b/HDMI-Tool/src/app_gui.py @@ -0,0 +1,695 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +HDMI-Tool 主界面 — CustomTkinter 双栏仪表盘 +================================================================ +布局(HDMI-Tool/UI参考布局.svg): + 左列 显示器选择 | 支持的模式 | EDID 信息 | 测试图案/纯色/自定义 | EDID 原始数据 + 右列 串口选择与参数 | 串口数据(收发日志) | 预设与自定义命令 | 光强/定时快捷滑条 +默认深色外观, 顶栏可切换 Dark/Light/System。 + +本模块只管界面: 显示枚举在 display_win, EDID 解析在 edid_parse, +投屏窗口在 patterns, 串口协议在 dlpc_rs485, 运行配置在 config。 +""" + +import ctypes +import os +import queue +import time +import tkinter as tk +from tkinter import filedialog, messagebox, ttk + +import customtkinter as ctk + +import config +import display_win +import dlpc_rs485 +import edid_parse +import patterns + +MONO = ('Consolas', 12) +BOLD = ('Microsoft YaHei UI', 13, 'bold') +TITLE_FONT = ('Microsoft YaHei UI', 18, 'bold') + + +def get_icon_path(): + """工具图标: 源码模式取 logos/ 下, 打包模式取 exe 内嵌资源(_MEIPASS)""" + import os + import sys + if getattr(sys, 'frozen', False): + p = os.path.join(getattr(sys, '_MEIPASS', ''), 'logo_ph_orange.ico') + else: + p = os.path.join(os.path.dirname(os.path.abspath(__file__)), + 'logos', 'logo_ph_orange.ico') + return p if os.path.exists(p) else None + + +def run_gui(auto_close=0): + """入口: 配置外观并启动主窗口""" + ctk.set_appearance_mode('Dark') + App(auto_close).mainloop() + + +class App(ctk.CTk): + def __init__(self, auto_close=0): + super().__init__() + self.title('HDMI-Tool — EDID 查看器 + 测试图案 + 光机控制 (LT8619C 转接板调试)') + self.geometry('1440x920') + self.minsize(1180, 800) + ic = get_icon_path() + if ic: + try: + self.iconbitmap(ic) # 窗口标题栏图标 + except Exception: + pass + self.displays = [] + self._disp_labels = [] + self._cur_label = '' + self.slots, self.area = config.load_slots() + self._modes = {} # 'WxH@Hz' -> DEVMODEW(当前所选屏) + 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_connected = False + self._poll_job = None + self._tv_style() + self._build() + self._load_displays() + if dlpc_rs485.HAS_SERIAL: + self._poll_dlpc() + if auto_close > 0: + self.after(auto_close * 1000, self.destroy) + + # ============================================================ + # 布局 + # ============================================================ + def _tv_style(self): + """内嵌 ttk.Treeview 的暗/亮配色(跟随外观切换)""" + self._style = ttk.Style(self) + try: + self._style.theme_use('clam') + except Exception: + pass + self._apply_tv_colors() + + def _apply_tv_colors(self): + dark = ctk.get_appearance_mode() == 'Dark' + s = self._style + s.configure('HDMI.Treeview', + background='#181818' if dark else '#F4F4F4', + fieldbackground='#181818' if dark else '#F4F4F4', + foreground='#DADADA' if dark else '#1A1A1A', + rowheight=24, borderwidth=0) + s.configure('HDMI.Treeview.Heading', + background='#242424' if dark else '#E8E8E8', + foreground='#B0B0B0' if dark else '#444444', + relief='flat') + s.map('HDMI.Treeview', background=[('selected', '#2A5D8F')]) + + def _set_appearance(self, mode): + ctk.set_appearance_mode(mode) + self._apply_tv_colors() + + def _card(self, parent, title, button=None, command=None): + """卡片容器: 圆角边框 + 标题行(可带右侧小按钮), 返回 (卡片, 内容 frame) + + 卡片自身用 grid 参与列布局; 卡片内部(head/content/行控件)一律用 pack。 + """ + card = ctk.CTkFrame(parent, corner_radius=10, border_width=1, + border_color=('gray70', 'gray30')) + head = ctk.CTkFrame(card, fg_color='transparent') + head.pack(fill='x', padx=12, pady=(8, 0)) + ctk.CTkLabel(head, text=title, font=BOLD, anchor='w').pack(side='left') + if button: + ctk.CTkButton(head, text=button, width=92, height=24, + command=command).pack(side='right') + content = ctk.CTkFrame(card, fg_color='transparent') + content.pack(fill='both', expand=True, padx=12, pady=(2, 10)) + return card, content + + def _build(self): + top = ctk.CTkFrame(self, fg_color='transparent') + top.pack(fill='x', padx=12, pady=(10, 2)) + ctk.CTkLabel(top, text='HDMI-Tool', font=TITLE_FONT).pack(side='left') + ctk.CTkLabel(top, text='EDID · 测试图案 · 光机控制', + text_color='gray60').pack(side='left', padx=10, pady=(6, 0)) + seg = ctk.CTkSegmentedButton(top, values=['Dark', 'Light', 'System'], + command=self._set_appearance) + seg.set('Dark') + seg.pack(side='right') + + body = ctk.CTkFrame(self, fg_color='transparent') + body.pack(fill='both', expand=True, padx=12, pady=(4, 12)) + body.grid_columnconfigure(0, weight=11, uniform='col') + body.grid_columnconfigure(1, weight=9, uniform='col') + body.grid_rowconfigure(0, weight=1) + left = ctk.CTkFrame(body, fg_color='transparent') + left.grid(row=0, column=0, sticky='nsew') + right = ctk.CTkFrame(body, fg_color='transparent') + right.grid(row=0, column=1, sticky='nsew', padx=(10, 0)) + self._build_left(left) + if dlpc_rs485.HAS_SERIAL: + self._build_right(right) + else: + hint_card, hint = self._card(right, '光机控制不可用') + hint_card.grid(row=0, column=0, sticky='nsew') + ctk.CTkLabel(hint, justify='left', wraplength=380, + text='未安装 pyserial。\n\n' + '执行 py -m pip install pyserial 后重启本工具。' + ).pack(expand=True) + + def _build_left(self, col): + col.grid_rowconfigure(1, weight=2) # 支持的模式 + col.grid_rowconfigure(2, weight=3) # EDID 信息 + col.grid_rowconfigure(4, weight=1) # EDID 原始数据 + col.grid_columnconfigure(0, weight=1) + + # -- 显示器选择区域 -- + card, c = self._card(col,'显示器选择区域') + card.grid(row=0, column=0, sticky='ew') + row = ctk.CTkFrame(c, fg_color='transparent') + row.pack(fill='x') + self.cbo = ctk.CTkComboBox(row, width=380, state='readonly', + command=lambda _=None: self._on_select()) + self.cbo.pack(side='left') + ctk.CTkButton(row, text='刷新', width=64, + command=self._load_displays).pack(side='left', padx=(6, 0)) + self.lbl_cur = ctk.CTkLabel(c, text='当前输出: —', anchor='w', + text_color='gray60') + self.lbl_cur.pack(fill='x', pady=(6, 0)) + + # -- 支持的模式 -- + card, c = self._card(col,'支持的模式 (EDID 解算)') + card.grid(row=1, column=0, sticky='nsew', pady=(10, 0)) + row = ctk.CTkFrame(c, fg_color='transparent') + row.pack(fill='x') + ctk.CTkLabel(row, text='分辨率:').pack(side='left') + self.cbo_mode = ctk.CTkComboBox(row, width=180, state='readonly') + self.cbo_mode.pack(side='left', padx=6) + ctk.CTkButton(row, text='应用', width=64, + command=self._apply_mode).pack(side='left') + ctk.CTkButton(row, text='恢复', width=64, + command=self._restore_mode).pack(side='left', padx=(6, 0)) + ctk.CTkLabel(row, text='临时切换, 重启还原', + text_color='gray50').pack(side='left', padx=8) + self.txt_modes = ctk.CTkTextbox(c, font=MONO, height=100) + self.txt_modes.pack(fill='both', expand=True, pady=(8, 0)) + self.txt_modes.configure(state='disabled') + + # -- EDID 信息 -- + card, c = self._card(col,'EDID 信息') + card.grid(row=2, column=0, sticky='nsew', pady=(10, 0)) + self.tv_info = ttk.Treeview(c, columns=('k', 'v'), show='headings', + height=8, style='HDMI.Treeview') + self.tv_info.heading('k', text='项目') + self.tv_info.heading('v', text='值') + self.tv_info.column('k', width=150, anchor='w') + self.tv_info.column('v', anchor='w') + sb = ttk.Scrollbar(c, orient='vertical', command=self.tv_info.yview) + self.tv_info.configure(yscrollcommand=sb.set) + self.tv_info.pack(side='left', fill='both', expand=True) + sb.pack(side='right', fill='y') + + # -- 测试图案 -- + card, c = self._card(col,'测试图案 — 黑底 1:1 居中, 按 ESC 退出') + card.grid(row=3, column=0, sticky='ew', pady=(10, 0)) + row1 = ctk.CTkFrame(c, fg_color='transparent') + row1.pack(fill='x') + for key, name in patterns.PATTERN_NAMES.items(): + ctk.CTkButton(row1, text=name, width=78, + command=lambda k=key: self._show_pattern(k) + ).pack(side='left', padx=(0, 6)) + ctk.CTkLabel(row1, text='测试卡区域:').pack(side='left', padx=(12, 4)) + self.area_var = tk.StringVar(value=self.area) + seg_area = ctk.CTkSegmentedButton(row1, values=list(config.AREA_OPTIONS), + command=self._save_area) + seg_area.set(self.area) + seg_area.pack(side='left') + row2 = ctk.CTkFrame(c, fg_color='transparent') + row2.pack(fill='x', pady=(6, 0)) + for ch, items in patterns.SOLID_ROWS: + ctk.CTkLabel(row2, text=f'{ch}: 0x').pack(side='left') + for label, color in items: + ctk.CTkButton(row2, text=label, width=38, height=24, + fg_color=color, hover_color=color, + text_color='#000000' if color == '#00FF00' else '#FFFFFF', + command=lambda cc=color: self._show_solid(cc) + ).pack(side='left', padx=3) + row3 = ctk.CTkFrame(c, fg_color='transparent') + row3.pack(fill='x', pady=(6, 0)) + self.slot_buttons = [] + for i in range(config.SLOT_COUNT): + cell = ctk.CTkFrame(row3, fg_color='transparent') + cell.pack(side='left', padx=(0, 10)) + btn = ctk.CTkButton(cell, text=self.slots[i]['name'], width=96, + command=lambda i=i: self._show_custom(i)) + btn.pack(side='left') + ctk.CTkButton(cell, text='⚙', width=30, height=24, + command=lambda i=i: self._configure_slot(i) + ).pack(side='left', padx=(4, 0)) + self.slot_buttons.append(btn) + ctk.CTkLabel(row3, text='点击投图, ⚙ 配置图片', + text_color='gray50').pack(side='left', padx=4) + + # -- EDID 原始数据 -- + card, c = self._card(col,'EDID 原始数据', + button='逐字节解析', command=self._show_edid_fields) + card.grid(row=4, column=0, sticky='nsew', pady=(10, 0)) + self.txt_hex = ctk.CTkTextbox(c, font=MONO, wrap='none') + self.txt_hex.pack(fill='both', expand=True) + self.txt_hex.configure(state='disabled') + + def _build_right(self, col): + col.grid_rowconfigure(1, weight=1) # 串口数据区域 + col.grid_columnconfigure(0, weight=1) + + # -- 串口选择与参数 -- + card, c = self._card(col,'串口 — RS485 → 板载 GD32 网关 → DLPC3421') + card.grid(row=0, column=0, sticky='ew') + row1 = ctk.CTkFrame(c, fg_color='transparent') + row1.pack(fill='x') + self._dlpc_ports = dlpc_rs485.DLPC485.list_ports_info() + self.cbo_dlpc = ctk.CTkComboBox(row1, width=250, state='readonly', + values=[lb for _d, lb in self._dlpc_ports]) + if config.SERIAL_CFG['port']: + self._dlpc_pick(config.SERIAL_CFG['port']) + self.cbo_dlpc.pack(side='left') + ctk.CTkButton(row1, text='刷新', width=64, + command=self._dlpc_refresh).pack(side='left', padx=(6, 0)) + self.btn_dlpc = ctk.CTkButton(row1, text='连接', width=72, + command=self._dlpc_toggle) + self.btn_dlpc.pack(side='left', padx=(6, 0)) + row2 = ctk.CTkFrame(c, fg_color='transparent') + row2.pack(fill='x', pady=(6, 0)) + ctk.CTkLabel(row2, text='波特率:').pack(side='left') + self.cbo_baud = ctk.CTkComboBox(row2, width=110, + values=('115200', '57600', '38400', '9600')) + self.cbo_baud.set(str(config.SERIAL_CFG['baud'])) + self.cbo_baud.pack(side='left', padx=6) + self.lbl_dlpc_st = ctk.CTkLabel(row2, text='未连接', text_color='gray60') + self.lbl_dlpc_st.pack(side='left', padx=6) + + # -- 串口数据区域 -- + card, c = self._card(col,'串口数据 — 收发日志(译码 + 原始 hex)', + button='清空', command=self._dlpc_clear) + card.grid(row=1, column=0, sticky='nsew', pady=(10, 0)) + self.txt_dlpc = ctk.CTkTextbox(c, font=MONO) + self.txt_dlpc.pack(fill='both', expand=True) + self.txt_dlpc.configure(state='disabled') + self._dlpc_log('帧格式: D5 01 LEN <命令> CRC / B5 TYPE LEN CRC, ' + '累加和校验; 全部命令见 README"光机控制"') + + # -- 光机命令 -- + card, c = self._card(col,'光机命令 — 预设与自定义') + card.grid(row=2, column=0, sticky='ew', pady=(10, 0)) + row1 = ctk.CTkFrame(c, fg_color='transparent') + row1.pack(fill='x') + for name, cmd in (('开光机', 'M730S0'), ('关光机', 'M730S1'), + ('查电流', 'M732'), ('查版本', 'M999'), ('软件重启', 'M888')): + ctk.CTkButton(row1, text=name, width=88, + command=lambda cc=cmd: self._dlpc_send(cc) + ).pack(side='left', padx=(0, 6)) + row2 = ctk.CTkFrame(c, fg_color='transparent') + row2.pack(fill='x', pady=(6, 0)) + ctk.CTkLabel(row2, text='预设:').pack(side='left') + self._dlpc_presets = [(n, cmd) for n, cmd, _ in dlpc_rs485.PRESETS] + self.cbo_dlpc_cmd = ctk.CTkComboBox( + row2, width=240, state='readonly', + values=[f'{n} {cmd}' for n, cmd in self._dlpc_presets]) + self.cbo_dlpc_cmd.set(self._dlpc_presets[0][0] + ' ' + self._dlpc_presets[0][1]) + self.cbo_dlpc_cmd.pack(side='left', padx=6) + ctk.CTkButton(row2, text='发送', width=64, + command=self._dlpc_send_preset).pack(side='left') + row3 = ctk.CTkFrame(c, fg_color='transparent') + row3.pack(fill='x', pady=(6, 0)) + ctk.CTkLabel(row3, text='自定义:').pack(side='left') + self.var_custom_cmd = tk.StringVar() + ent = ctk.CTkEntry(row3, width=180, textvariable=self.var_custom_cmd, + placeholder_text='如 M730S0T2000') + ent.pack(side='left', padx=6) + ent.bind('', lambda _e: self._dlpc_send_custom()) + ctk.CTkButton(row3, text='发送', width=64, + command=self._dlpc_send_custom).pack(side='left') + + # -- 快捷滑条 -- + card, c = self._card(col,'快捷滑条 — 光强 / 定时投光') + card.grid(row=3, 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') + self.var_light = tk.IntVar(value=100) + sld = ctk.CTkSlider(rowa, from_=54, to=800, number_of_steps=149, + command=lambda v: self.var_light.set(int(round(float(v))))) + sld.set(100) + sld.pack(side='left', fill='x', expand=True, padx=8) + ctk.CTkLabel(rowa, textvariable=self.var_light, width=40).pack(side='left') + ctk.CTkButton(rowa, text='下发 M731S', width=110, + command=self._dlpc_send_light).pack(side='left', padx=(6, 0)) + rowb = ctk.CTkFrame(c, fg_color='transparent') + rowb.pack(fill='x', pady=(8, 0)) + ctk.CTkLabel(rowb, text='定时投光(秒):').pack(side='left') + self.var_timer = tk.IntVar(value=2) + sld_t = ctk.CTkSlider(rowb, from_=0, to=32, number_of_steps=32, + command=lambda v: self.var_timer.set(int(round(float(v))))) + sld_t.set(2) + sld_t.pack(side='left', fill='x', expand=True, padx=8) + ctk.CTkLabel(rowb, textvariable=self.var_timer, width=40).pack(side='left') + ctk.CTkButton(rowb, text='开光机(定时)', width=110, + command=self._dlpc_send_timer).pack(side='left', padx=(6, 0)) + + # ============================================================ + # 显示器 / 分辨率 / EDID + # ============================================================ + @staticmethod + def _ro(txt): + txt.configure(state='disabled') + + def _set_text(self, txt, content): + txt.configure(state='normal') + txt.delete('1.0', 'end') + txt.insert('1.0', content) + txt.configure(state='disabled') + + def _cur_display(self): + if self.cbo.get() in self._disp_labels: + return self.displays[self._disp_labels.index(self.cbo.get())] + return None + + def _load_displays(self): + prev = self._cur_label + self.displays = display_win.get_displays() + self._disp_labels = [f"{d['device']} {d['adapter']} [{d['mon_name']}]" + for d in self.displays] + self.cbo.configure(values=self._disp_labels) + if not self.displays: + self._cur_label = '' + self.lbl_cur.configure(text='未找到活动显示器') + return + idx = self._disp_labels.index(prev) if prev in self._disp_labels else 0 + self._cur_label = self._disp_labels[idx] + self.cbo.set(self._cur_label) + self._on_select() + + def _on_select(self, _evt=None): + if self.cbo.get() not in self._disp_labels: + return + self._cur_label = self.cbo.get() + d = self._cur_display() + self.lbl_cur.configure(text=f"当前输出: {d['cur_mode']}") + keys, modes = display_win.enum_modes(d['device']) + self._modes = modes + self.cbo_mode.configure(values=keys) + if d['cur_mode'] in modes: + self.cbo_mode.set(d['cur_mode']) + elif keys: + self.cbo_mode.set(keys[0]) + self.tv_info.delete(*self.tv_info.get_children()) + if not d['edid']: + self.tv_info.insert('', 'end', + values=('EDID', '读取失败(注册表无 EDID 数据)')) + self._set_text(self.txt_modes, '') + self._set_text(self.txt_hex, '') + return + e = edid_parse.parse_edid(d['edid']) + for k, v in edid_parse.edid_info_lines(e): + self.tv_info.insert('', 'end', values=(k, v)) + lines = [] + for k, t in enumerate(e['dtds']): + src = 'DTD 首选' if k == 0 else 'DTD' + lines.append(f"{t['hact']}x{t['vact']} @{t['refresh']:g}Hz " + f"{t['clk_khz'] / 1000:.2f}MHz {src}") + for m in e['cea_video']: + lines.append(f'{m} [CEA-VIC]') + for m in e['est']: + lines.append(f'{m} [已建立时序]') + for m in e['std']: + lines.append(f'{m} [标准时序]') + self._set_text(self.txt_modes, '\n'.join(lines)) + self._set_text(self.txt_hex, edid_parse.hex_dump(d['edid'])) + + def _apply_mode(self): + d = self._cur_display() + if d is None or self.cbo_mode.get() not in self._modes: + return + dev = d['device'] + if dev not in self._orig_modes: # 记住首次切改前的原始模式, 供"恢复"用 + cur = display_win.DEVMODEW() + cur.dmSize = ctypes.sizeof(cur) + if display_win.user32.EnumDisplaySettingsW( + dev, display_win.ENUM_CURRENT_SETTINGS, ctypes.byref(cur)): + self._orig_modes[dev] = cur + ok, r = display_win.apply_mode(dev, self._modes[self.cbo_mode.get()]) + if not ok: + messagebox.showerror('切换失败', f'ChangeDisplaySettingsEx 返回码 {r}') + return + self.after(500, self._load_displays) # 等模式生效后刷新矩形/EDID 面板 + + def _restore_mode(self): + d = self._cur_display() + if d is None: + return + dm = self._orig_modes.pop(d['device'], None) + if dm is None: + messagebox.showinfo('无可恢复', '该显示器没有记录切改前的模式') + return + display_win.apply_mode(d['device'], dm) + self.after(500, self._load_displays) + + def _show_pattern(self, kind): + d = self._cur_display() + if d and d['rect']: + patterns.show_pattern(self, d['rect'], kind, area=self.area_var.get()) + + def _show_solid(self, color): + d = self._cur_display() + if d and d['rect']: + patterns.show_pattern(self, d['rect'], color, area=self.area_var.get()) + + def _save_area(self, value): + self.area_var.set(value) + self.area = value + config.save_slots(self.slots, self.area) + + def _show_custom(self, idx): + slot = self.slots[idx] + if not slot['file']: + messagebox.showinfo('未配置图片', f'请先点击"⚙"为"{slot["name"]}"配置图片。') + return + path = os.path.join(config.BASE_DIR, slot['file']) + if not os.path.exists(path): + messagebox.showerror('图片不存在', path) + return + d = self._cur_display() + if d and d['rect']: + patterns.show_pattern_image(self, d['rect'], path, area=self.area_var.get()) + + def _configure_slot(self, idx): + dlg = ctk.CTkToplevel(self) + dlg.title(f'配置自定义栏位 {idx + 1}') + dlg.resizable(False, False) + dlg.grab_set() + dlg.after(150, dlg.grab_set) # CTkToplevel 焦点时序 + + name_var = tk.StringVar(value=self.slots[idx]['name']) + file_var = tk.StringVar(value=self.slots[idx]['file'] or '(未设置)') + + body = ctk.CTkFrame(dlg, fg_color='transparent') + body.pack(padx=16, pady=14) + ctk.CTkLabel(body, text='按钮名称:').grid(row=0, column=0, sticky='w', pady=4) + ctk.CTkEntry(body, textvariable=name_var, width=240 + ).grid(row=0, column=1, columnspan=2, sticky='w', padx=6) + ctk.CTkLabel(body, text='图片文件:').grid(row=1, column=0, sticky='w', pady=4) + ctk.CTkLabel(body, textvariable=file_var, text_color='#3B8ED0' + ).grid(row=1, column=1, columnspan=2, sticky='w', padx=6) + if not config.HAS_PIL: + ctk.CTkLabel(body, text='(未安装 Pillow, 仅支持 PNG/GIF; ' + '安装 pillow 后可用 JPG/BMP/WebP)', + text_color='#E07A3F').grid(row=2, column=0, columnspan=3, + sticky='w') + + def choose(): + exts = ' '.join(f'*{e}' for e in config.IMG_EXTS) + path = filedialog.askopenfilename(parent=dlg, title='选择图片', + filetypes=[('图片文件', exts), + ('所有文件', '*.*')]) + if path: + try: + file_var.set(config.copy_image_into_script_dir(path)) + except OSError as ex: + messagebox.showerror('复制图片失败', str(ex), parent=dlg) + + def save(): + name = name_var.get().strip() or f'自定义{idx + 1}' + fname = file_var.get() + self.slots[idx] = {'name': name, + 'file': None if fname.startswith('(') else fname} + config.save_slots(self.slots, self.area_var.get()) + self.slot_buttons[idx].configure(text=name) + dlg.destroy() + + btns = ctk.CTkFrame(body, fg_color='transparent') + btns.grid(row=3 if not config.HAS_PIL else 2, column=0, + columnspan=3, sticky='w', pady=(10, 0)) + ctk.CTkButton(btns, text='选择图片...', width=110, + command=choose).pack(side='left', padx=(0, 6)) + ctk.CTkButton(btns, text='清除图片', width=90, + command=lambda: file_var.set('(未设置)')).pack(side='left', padx=(0, 6)) + ctk.CTkButton(btns, text='保存', width=70, command=save).pack(side='left', padx=(0, 6)) + ctk.CTkButton(btns, text='取消', width=70, fg_color='gray40', + hover_color='gray25', command=dlg.destroy).pack(side='left') + + def _show_edid_fields(self): + d = self._cur_display() + if d is None: + return + if not d['edid']: + messagebox.showinfo('无 EDID', '该显示器注册表中没有 EDID 数据, 无法逐字节解析') + return + dlg = ctk.CTkToplevel(self) + dlg.title(f'EDID 逐字节解析 — {d["device"]} [{d["mon_name"]}]') + dlg.geometry('1120x700') + dlg.after(150, dlg.grab_set()) + txt = ctk.CTkTextbox(dlg, font=MONO, wrap='char') + txt.pack(fill='both', expand=True, padx=8, pady=(8, 4)) + try: # 深底配色(个别 CTk 版本不支持 tag 时退化为单色) + txt.tag_config('field', foreground='#E8E8E8') + txt.tag_config('raw', foreground='#7F7F7F') + txt.tag_config('note', foreground='#9FB8CC') + except Exception: + pass + for off, hx, meaning, val, note in edid_parse.edid_field_rows(d['edid']): + try: + txt.insert('end', f'{off} {meaning} {val}\n', 'field') + if hx: + txt.insert('end', f' 原始: {hx}\n', 'raw') + if note: + txt.insert('end', f' {note}\n', 'note') + except tk.TclError: # 个别 CTk 版本不支持 tag 插入, 退化为单色 + txt.insert('end', f'{off} {meaning} {val}\n') + self._ro(txt) + ctk.CTkButton(dlg, text='关闭', width=90, command=dlg.destroy).pack(pady=(0, 8)) + + # ============================================================ + # 光机控制(RS485) + # ============================================================ + def _dlpc_on_event(self, line): + self._dlpc_log_q.put(('event', line)) + + def _dlpc_on_status(self, msg): + self._dlpc_log_q.put(('status', msg)) + + def _poll_dlpc(self): + """串口线程 -> GUI 主线程: 80ms 轮询日志队列并同步连接状态""" + try: + while True: + kind, text = self._dlpc_log_q.get_nowait() + if kind == 'status': + if text.startswith('已连接'): + color = '#2FA574' + elif ('失败' in text or '丢失' in text or '未' in text + or '非法' in text or '丢弃' in text): + color = '#E07A3F' + else: + color = 'gray60' + self.lbl_dlpc_st.configure(text=text, text_color=color) + self._dlpc_log(text) + except queue.Empty: + pass + connected = self.dlpc.is_connected() + if connected != self._dlpc_connected: + self._dlpc_connected = connected + self.btn_dlpc.configure(text='断开' if connected else '连接') + self._poll_job = self.after(80, self._poll_dlpc) + + def _dlpc_log(self, line): + self.txt_dlpc.configure(state='normal') + self.txt_dlpc.insert('end', time.strftime('%H:%M:%S ') + line + '\n') + if int(self.txt_dlpc.index('end-1c').split('.')[0]) > 500: + self.txt_dlpc.delete('1.0', '2.0') # 只保留最近约 500 行 + self.txt_dlpc.see('end') + self.txt_dlpc.configure(state='disabled') + + def _dlpc_clear(self): + self._set_text(self.txt_dlpc, '') + + def _dlpc_refresh(self): + self._dlpc_ports = dlpc_rs485.DLPC485.list_ports_info() + self.cbo_dlpc.configure(values=[lb for _d, lb in self._dlpc_ports]) + devices = [d for d, _l in self._dlpc_ports] + if config.SERIAL_CFG['port'] in devices: + self._dlpc_pick(config.SERIAL_CFG['port']) + elif devices: + self._dlpc_pick(devices[0]) + + def _dlpc_pick(self, device): + """按 COM 口名选中下拉项(下拉显示的是带描述的标签)""" + for i, (d, label) in enumerate(self._dlpc_ports): + if d == device: + self.cbo_dlpc.set(label) + return + + def _dlpc_selected_port(self): + label = self.cbo_dlpc.get() + for d, lb in self._dlpc_ports: + if lb == label: + return d + return label.strip() + + def _dlpc_toggle(self): + if self.dlpc.is_connected(): + self.dlpc.close() + return + port = self._dlpc_selected_port() + if not port: + self.lbl_dlpc_st.configure(text='未选择串口', text_color='#E07A3F') + return + try: + baud = int(self.cbo_baud.get()) + except ValueError: + self.lbl_dlpc_st.configure(text='波特率非法', text_color='#E07A3F') + return + config.SERIAL_CFG['port'], config.SERIAL_CFG['baud'] = port, baud + config.save_slots(self.slots, self.area_var.get()) # 记住本次串口 + self.dlpc.open(port, baud) + + def _dlpc_send(self, cmd): + if not self.dlpc.is_connected(): + self.lbl_dlpc_st.configure(text='未连接串口', text_color='#E07A3F') + return + self.dlpc.send(cmd) + + def _dlpc_send_preset(self): + label = self.cbo_dlpc_cmd.get() + for name, cmd in self._dlpc_presets: + if label.startswith(name + ' '): + self._dlpc_send(cmd) + return + + def _dlpc_send_custom(self): + cmd = self.var_custom_cmd.get().strip() + if cmd: + self._dlpc_send(cmd) + + def _dlpc_send_light(self): + v = int(self.var_light.get()) + if not 54 <= v <= 800: + messagebox.showinfo('光强越界', '光强范围 54-800(越界固件会回 err:OL)') + return + self._dlpc_send(f'M731S{v}') + + def _dlpc_send_timer(self): + v = int(self.var_timer.get()) + self._dlpc_send('M730S0' if v <= 0 else f'M730S0T{v * 1000}') + + def destroy(self): + try: # 取消待触发的轮询回调, 避免销毁后 Tcl 报错 + if self._poll_job: + self.after_cancel(self._poll_job) + except Exception: + pass + try: # 两种退出路径(关窗/--seconds 自动关)都收掉串口线程 + self.dlpc.close() + except Exception: + pass + super().destroy() diff --git a/HDMI-Tool/src/build_exe.py b/HDMI-Tool/src/build_exe.py index a28331d..f9dfbef 100644 --- a/HDMI-Tool/src/build_exe.py +++ b/HDMI-Tool/src/build_exe.py @@ -34,6 +34,16 @@ def ensure_pyinstaller(): subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'pyinstaller']) +def extra_collect_args(): + """CustomTkinter 的主题/字体等数据文件必须显式收集, 否则打包后 GUI 启动报错""" + try: + import customtkinter # noqa: F401 + return ['--collect-all', 'customtkinter'] + except ImportError: + print('警告: 未安装 customtkinter, 打包产物将无法启动 GUI!') + return [] + + def remove_stale(exe_path): """删除旧 exe; 若被占用(上次验证的进程还在), 先结束同名进程再重试""" if not os.path.exists(exe_path): @@ -58,7 +68,7 @@ def build(name, extra_args): cmd = [sys.executable, '-m', 'PyInstaller', '--onefile', '--clean', '--name', name, '--distpath', dist, '--workpath', work, '--specpath', work, - ] + extra_args + [os.path.join(SRC, 'hdmi_gui.py')] + ] + extra_args + extra_collect_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/HDMI-Tool/src/config.py b/HDMI-Tool/src/config.py new file mode 100644 index 0000000..63d80e6 --- /dev/null +++ b/HDMI-Tool/src/config.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +工具运行配置: 运行目录定位 / pattern_slots.json(自定义栏位 + 串口记忆) +================================================================ +PyInstaller onefile 打包后 __file__ 指向临时解压目录, +配置必须落在 exe 旁边, 故 frozen 时改用 sys.executable 定位。 +""" + +import json +import os +import shutil +import sys + +import dlpc_rs485 + +# 可选 Pillow: 装了则支持 JPEG/BMP/WebP, 没装则用 tkinter 原生(仅 PNG/GIF) +# Image/ImageTk 在此导入一次, patterns.py 复用同一份对象 +try: + from PIL import Image, ImageTk # noqa: F401 + HAS_PIL = True +except ImportError: + Image = ImageTk = None + HAS_PIL = False + +# 测试卡区域常量(栏位配置与投屏共用) +PAT_W, PAT_H = 640, 360 +AREA_OPTIONS = ('640x360', '800x600', 'full') +DEFAULT_AREA = AREA_OPTIONS[0] + + +# ============================================================ +# 自定义图案栏位: 配置存于脚本目录 pattern_slots.json, +# 图片文件同样保存在脚本目录下 +# 注: PyInstaller onefile 打包后 __file__ 指向临时解压目录, +# 配置必须落在 exe 旁边, 故 frozen 时改用 sys.executable 定位 +# ============================================================ +if getattr(sys, 'frozen', False): + BASE_DIR = os.path.dirname(os.path.abspath(sys.executable)) +else: + BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +SLOT_COUNT = 3 +SLOT_CONFIG = os.path.join(BASE_DIR, 'pattern_slots.json') +IMG_EXTS = ['.png', '.gif'] + (['.jpg', '.jpeg', '.bmp', '.webp'] if HAS_PIL else []) + +# 光机控制串口记忆(与栏位配置同存 pattern_slots.json 的 "serial" 键) +SERIAL_CFG = {'port': '', 'baud': dlpc_rs485.DEFAULT_BAUD} + + +def load_slots(): + """读 pattern_slots.json, 返回 (slots, area)。 + + 新格式为 {'area': 'WxH'|'full', 'slots': [...], 'serial': {...}}; + 兼容旧版纯数组格式(无 area)与无 serial 键的中间格式。 + """ + slots = [{'name': f'自定义{i + 1}', 'file': None} for i in range(SLOT_COUNT)] + try: + with open(SLOT_CONFIG, encoding='utf-8') as f: + data = json.load(f) + except Exception: + return slots, DEFAULT_AREA + if isinstance(data, dict): + area = str(data.get('area') or DEFAULT_AREA) + items = data.get('slots') or [] + ser = data.get('serial') + if isinstance(ser, dict): + SERIAL_CFG['port'] = str(ser.get('port') or '') + try: + SERIAL_CFG['baud'] = int(ser.get('baud') or dlpc_rs485.DEFAULT_BAUD) + except (TypeError, ValueError): + pass + else: + area, items = DEFAULT_AREA, data + for i, item in enumerate(items[:SLOT_COUNT]): + if isinstance(item, dict): + slots[i]['name'] = str(item.get('name') or slots[i]['name']) + slots[i]['file'] = item.get('file') or None + return slots, area + + +def save_slots(slots, area): + with open(SLOT_CONFIG, 'w', encoding='utf-8') as f: + json.dump({'area': area, 'slots': slots, 'serial': SERIAL_CFG}, + f, ensure_ascii=False, indent=2) + + +def copy_image_into_script_dir(src_path): + """把任意路径的图片复制到脚本目录, 重名自动加序号, 返回文件名""" + dst_name = os.path.basename(src_path) + dst = os.path.join(BASE_DIR, dst_name) + if os.path.normcase(os.path.abspath(src_path)) == os.path.normcase(dst): + return dst_name + stem, ext = os.path.splitext(dst_name) + n = 1 + while os.path.exists(dst): + dst_name = f'{stem}_{n}{ext}' + dst = os.path.join(BASE_DIR, dst_name) + n += 1 + shutil.copy2(src_path, dst) + return dst_name diff --git a/HDMI-Tool/src/display_win.py b/HDMI-Tool/src/display_win.py new file mode 100644 index 0000000..d31fa13 --- /dev/null +++ b/HDMI-Tool/src/display_win.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Win32 显示器枚举 / 分辨率切换 / 显示拓扑(纯 ctypes, 无第三方依赖) +================================================================ +get_displays() 枚举活动显示器(设备名/显卡/监视器/当前模式/矩形/EDID) +enum_modes() 显卡按该屏 EDID 提供的模式列表 +apply_mode() 临时切换分辨率(不写注册表, 重启还原) +ensure_display_extend() 智能拓扑: 仅转接板(EDID 厂商 XLS)接入但未进桌面时强制扩展 +""" + +import ctypes +import ctypes.wintypes as wt +import winreg + +# ============================================================ +# Win32 显示 API +# ============================================================ +user32 = ctypes.windll.user32 + +ENUM_CURRENT_SETTINGS = 0xFFFFFFFF # (DWORD)-1 + + +class DISPLAY_DEVICEW(ctypes.Structure): + _fields_ = [ + ('cb', wt.DWORD), + ('DeviceName', ctypes.c_wchar * 32), + ('DeviceString', ctypes.c_wchar * 128), + ('StateFlags', wt.DWORD), + ('DeviceID', ctypes.c_wchar * 128), + ('DeviceKey', ctypes.c_wchar * 128), + ] + + +class DEVMODEW(ctypes.Structure): + _fields_ = [ + ('dmDeviceName', ctypes.c_wchar * 32), + ('dmSpecVersion', wt.WORD), + ('dmDriverVersion', wt.WORD), + ('dmSize', wt.WORD), + ('dmDriverExtra', wt.WORD), + ('dmFields', wt.DWORD), + ('dmPositionX', wt.LONG), + ('dmPositionY', wt.LONG), + ('dmDisplayOrientation', wt.DWORD), + ('dmDisplayFixedOutput', wt.DWORD), + ('dmColor', wt.WORD), + ('dmDuplex', wt.WORD), + ('dmYResolution', wt.WORD), + ('dmTTOption', wt.WORD), + ('dmCollate', wt.WORD), + ('dmFormName', ctypes.c_wchar * 32), + ('dmLogPixels', wt.WORD), + ('dmBitsPerPel', wt.DWORD), + ('dmPelsWidth', wt.DWORD), + ('dmPelsHeight', wt.DWORD), + ('dmDisplayFlags', wt.DWORD), + ('dmDisplayFrequency', wt.DWORD), + ('dmICMMethod', wt.DWORD), + ('dmICMIntent', wt.DWORD), + ('dmMediaType', wt.DWORD), + ('dmDitherType', wt.DWORD), + ('dmReserved1', wt.DWORD), + ('dmReserved2', wt.DWORD), + ('dmPanningWidth', wt.DWORD), + ('dmPanningHeight', wt.DWORD), + ] + + +user32.EnumDisplayDevicesW.argtypes = [ctypes.c_wchar_p, wt.DWORD, + ctypes.POINTER(DISPLAY_DEVICEW), wt.DWORD] +user32.EnumDisplayDevicesW.restype = wt.BOOL +user32.EnumDisplaySettingsW.argtypes = [ctypes.c_wchar_p, wt.DWORD, + ctypes.POINTER(DEVMODEW)] +user32.EnumDisplaySettingsW.restype = wt.BOOL +user32.ChangeDisplaySettingsExW.argtypes = [ctypes.c_wchar_p, ctypes.POINTER(DEVMODEW), + ctypes.c_void_p, wt.DWORD, ctypes.c_void_p] +user32.ChangeDisplaySettingsExW.restype = ctypes.c_long + +DM_BITSPERPEL = 0x00040000 +DM_PELSWIDTH = 0x00080000 +DM_PELSHEIGHT = 0x00100000 +DM_DISPLAYFREQUENCY = 0x00400000 + + +def read_edid_from_registry(device_id): + """monitor DeviceID 'MONITOR\\\\' → 注册表 EDID 原始字节 + + 注意: 间接显示器/虚拟显示驱动返回的 DeviceID 实例段是 '{类GUID}\\NNNN' + 格式, 注册表中并不存在该路径; 因此精确匹配失败后按 PNP ID 枚举其下 + 全部实例, 取第一份 EDID(同一物理屏的重复实例 EDID 相同)。 + """ + if not device_id or not device_id.startswith('MONITOR\\'): + return None + parts = device_id.split('\\') + if len(parts) < 3: + return None + pnp = parts[1] + inst = device_id.split('\\', 2)[2] + + def _read(path): + try: + key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path) + try: + val, _typ = winreg.QueryValueEx(key, 'EDID') + if isinstance(val, (bytes, bytearray)): + return bytes(val) + if isinstance(val, list): # 某些系统返回 int 列表 + return bytes(val) + finally: + winreg.CloseKey(key) + except OSError: + pass + return None + + # 1) 精确实例路径(常规真实显示器) + raw = _read(rf'SYSTEM\CurrentControlSet\Enum\DISPLAY\{pnp}\{inst}\Device Parameters') + if raw: + return raw + + # 2) 按 PNP ID 遍历全部实例(间接显示器/实例名不一致) + try: + pnp_root = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, + rf'SYSTEM\CurrentControlSet\Enum\DISPLAY\{pnp}') + except OSError: + return None + try: + for i in range(winreg.QueryInfoKey(pnp_root)[0]): + sub = winreg.EnumKey(pnp_root, i) + raw = _read(rf'SYSTEM\CurrentControlSet\Enum\DISPLAY\{pnp}\{sub}\Device Parameters') + if raw: + return raw + finally: + winreg.CloseKey(pnp_root) + return None + + +def get_displays(): + """枚举活动显示器: 设备名/显卡/监视器名/当前模式/屏幕矩形/EDID 字节""" + out = [] + i = 0 + while True: + dd = DISPLAY_DEVICEW() + dd.cb = ctypes.sizeof(dd) + if not user32.EnumDisplayDevicesW(None, i, ctypes.byref(dd), 0): + break + if dd.StateFlags & 0x1: # ATTACHED_TO_DESKTOP + # 找该输出口上活动的 monitor 条目 + mon = None + j = 0 + while True: + m = DISPLAY_DEVICEW() + m.cb = ctypes.sizeof(m) + if not user32.EnumDisplayDevicesW(dd.DeviceName, j, ctypes.byref(m), 0): + break + if m.StateFlags & 0x1: + mon = m + break + j += 1 + + cur = '' + rect = None + dm = DEVMODEW() + dm.dmSize = ctypes.sizeof(dm) + if user32.EnumDisplaySettingsW(dd.DeviceName, ENUM_CURRENT_SETTINGS, + ctypes.byref(dm)): + cur = f'{dm.dmPelsWidth}x{dm.dmPelsHeight}@{dm.dmDisplayFrequency}' + rect = (dm.dmPositionX, dm.dmPositionY, dm.dmPelsWidth, dm.dmPelsHeight) + + out.append({ + 'device': dd.DeviceName, # \\.\DISPLAY1 + 'adapter': dd.DeviceString, # 显卡名 + 'mon_name': mon.DeviceString if mon else '', + 'edid': read_edid_from_registry(mon.DeviceID) if mon else None, + 'cur_mode': cur, + 'rect': rect, # (x, y, w, h) 物理像素 + }) + i += 1 + return out + + +def enum_modes(device): + """枚举 device(\\\\.\\DISPLAYn) 可用模式 —— 显卡按该屏 EDID 提供, + 返回 (['WxH@Hz', ...] 按像素数降序, {'WxH@Hz': DEVMODEW})""" + seen = {} + i = 0 + while True: + dm = DEVMODEW() + dm.dmSize = ctypes.sizeof(dm) + if not user32.EnumDisplaySettingsW(device, i, ctypes.byref(dm)): + break + i += 1 + if dm.dmBitsPerPel != 32: + continue + key = f'{dm.dmPelsWidth}x{dm.dmPelsHeight}@{dm.dmDisplayFrequency}' + seen.setdefault(key, dm) + keys = sorted(seen, key=lambda k: tuple(int(v) for v in k.replace('@', 'x').split('x')), + reverse=True) + return keys, seen + + +def apply_mode(device, dm): + """把 device 切到 dm 模式(临时切换, 不写注册表, 重启还原), 返回 (成功?, 返回码)""" + dm.dmFields = DM_BITSPERPEL | DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY + r = user32.ChangeDisplaySettingsExW(device, ctypes.byref(dm), None, 0, None) + return r == 0, r # DISP_CHANGE_SUCCESSFUL == 0 + + +def ensure_display_extend(): + """仅当转接板显示器"已接入但未进入桌面"时, 才强制扩展拓扑。 + + 场景: 板卡热插拔后, 若 Windows 停留在"仅电脑屏幕"拓扑(会记住上次 + Win+P 的选择), 板卡的 EDID 虽已安装、GPU 也在按首选时序输出, 但桌面 + 并未扩展过去, get_displays() 就枚举不到这块"显示器" —— 此时才需要 + 强制扩展把它拉回桌面。 + + 用户主动选择的拓扑(家里的"仅第二屏幕"、手动关闭的显示器等)一律不动: + 枚举"接入但不在桌面"的显示器, 只有其 EDID 厂商是转接板签名 "XLS" + (字节 0x61 0x93, 上游 lt8619c_edid.c) 时才扩展; 笔记本内屏等其它 + 厂商视为用户有意关闭, 不干预。 + + 返回 SetDisplayConfig 的返回码(0=成功, -1=未触发, 其它=异常); 静默容错。 + """ + try: + board_stuck = False + i = 0 + while True: + dd = DISPLAY_DEVICEW() + dd.cb = ctypes.sizeof(dd) + if not user32.EnumDisplayDevicesW(None, i, ctypes.byref(dd), 0): + break + i += 1 + j = 0 + while True: + m = DISPLAY_DEVICEW() + m.cb = ctypes.sizeof(m) + if not user32.EnumDisplayDevicesW(dd.DeviceName, j, ctypes.byref(m), 0): + break + j += 1 + if m.StateFlags & 0x1: # ATTACHED_TO_DESKTOP: 已在桌面 + continue + raw = read_edid_from_registry(m.DeviceID) + if len(raw or ()) >= 10 and raw[8:10] == b'\x61\x93': + board_stuck = True # 转接板没进桌面 -> 需要拉回 + if not board_stuck: + return -1 + SDC_TOPOLOGY_EXTEND = 0x00000004 + SDC_APPLY = 0x00000080 + return ctypes.windll.user32.SetDisplayConfig( + 0, None, 0, None, SDC_TOPOLOGY_EXTEND | SDC_APPLY) + except Exception: + return -1 + diff --git a/HDMI-Tool/src/edid_parse.py b/HDMI-Tool/src/edid_parse.py new file mode 100644 index 0000000..bf7fdb4 --- /dev/null +++ b/HDMI-Tool/src/edid_parse.py @@ -0,0 +1,548 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +EDID 1.4 / CEA-861 解析(纯标准库) +================================================================ +parse_edid() 原始 EDID 字节 -> 结构化 dict(DTD/已建立/标准/CEA-VIC/数据块) +edid_info_lines() EDID 信息卡片用的 (项目, 值) 行 +edid_field_rows()/edid_field_text() 逐字节字段解析 +hex_dump() 经典 hex + ASCII 排版 +""" + +import math +import struct + +# ============================================================ +# EDID 1.4 / CEA-861 解析 +# ============================================================ +# CTA-861 VIC 1..64 常用映射(65+ 及扩展 VIC 不展开) +VIC_TABLE = [ + '640x480@60', '720x480@60 4:3', '720x480@60 16:9', '1280x720@60', '1920x1080i@60', + '720x480i@60 4:3', '720x480i@60 16:9', '720x240p@60 4:3', '720x240p@60 16:9', + '1440x480i@60 4:3', '1440x480i@60 16:9', '1440x240p@60 4:3', '1440x240p@60 16:9', + '1440x480p@60 4:3', '1440x480p@60 16:9', '1920x1080p@60', '720x576p@50 4:3', '720x576p@50 16:9', + '1280x720p@50', '1920x1080i@50', '720x576i@50 4:3', '720x576i@50 16:9', + '720x288p@50 4:3', '720x288p@50 16:9', + '1440x576i@50 4:3', '1440x576i@50 16:9', '1440x288p@50 4:3', '1440x288p@50 16:9', + '1440x576p@50 4:3', '1440x576p@50 16:9', '1920x1080p@50', '1920x1080p@24', + '1920x1080p@25', '1920x1080p@30', + '2880x480p@60 4:3', '2880x480p@60 16:9', '2880x576p@50 4:3', '2880x576p@50 16:9', + '1920x1080i@50(1250)', '1920x1080i@100', '1280x720p@100', + '720x576p@100 4:3', '720x576p@100 16:9', '720x576i@100 4:3', '720x576i@100 16:9', + '1920x1080i@120', '1280x720p@120', + '720x480p@120 4:3', '720x480p@120 16:9', '720x480i@120 4:3', '720x480i@120 16:9', + '720x576p@200 4:3', '720x576p@200 16:9', '720x576i@200 4:3', '720x576i@200 16:9', + '720x480p@240 4:3', '720x480p@240 16:9', '720x480i@240 4:3', '720x480i@240 16:9', + '1280x720p@24', '1280x720p@25', '1280x720p@30', '1920x1080p@120', '1920x1080p@240', + # VIC 65-88 为 3D/特殊格式, 少见不展开 + *[None] * 24, + '2560x1080p@50', '2560x1080p@60', '2560x1080p@100', '2560x1080p@120', + '3840x2160p@24', '3840x2160p@25', '3840x2160p@30', + '3840x2160p@50', '3840x2160p@60', '4096x2160p@24', +] + +# 已建立时序位域表(bytes 35-37, 共 24 位, '-' 为保留位) +EST_TABLE = [ + '720x400@70', '720x400@88', '640x480@60', '640x480@67', '640x480@72', '640x480@75', + '800x600@56', '800x600@60', + '800x600@72', '800x600@75', '832x624@75', '1024x768@87i', '1024x768@60', '1024x768@70', + '1024x768@75', '1280x1024@75', + '1152x870@75', '-', '-', '-', '-', '-', '-', '-', +] + +STD_ASPECT = {0: (10 / 16, '16:10'), 1: (3 / 4, '4:3'), 2: (4 / 5, '5:4'), 3: (9 / 16, '16:9')} +DEPTHS = [0, 6, 8, 10, 12, 14, 16, 0] +INTERFACES = ['未定义', 'DVI', 'HDMI-A', 'HDMI-B', 'MDDI', 'DisplayPort', '保留', '保留'] +SYNC_TYPES = ['模拟合成', '双极模拟合成', '数字合成', '数字分离'] + +# CEA-861 数据块 tag(CTA-861 标准): 1=音频 2=视频 3=厂商专用(VSDB) +# 4=扬声器分配 5=VESA DTC 6=视频格式 7=扩展 tag 集合 +EXT_TAG_NAMES = { + 0x00: '视频能力', 0x01: '厂商扩展', 0x02: 'VESA 显示设备', 0x04: 'HDMI 4K/8K 视频格式', + 0x05: '色度学', 0x06: 'HDR 静态元数据', 0x07: 'HDR 动态元数据', 0x0C: 'YCbCr4:2:0 能力', + 0x0E: 'YCbCr4:2:0 视频格式', 0x78: 'HDMI 论坛 SCDB', +} + + +def parse_dtd(raw, o): + """解析 18 字节详细时序描述符, 返回 dict""" + t = {} + t['clk_khz'] = struct.unpack_from('> 4) | ((raw[o + 11] & 0x0C) << 2) + t['vsync'] = (raw[o + 10] & 0x0F) | ((raw[o + 11] & 0x03) << 4) + t['him_mm'] = raw[o + 12] | ((raw[o + 14] & 0xF0) << 4) + t['vim_mm'] = raw[o + 13] | ((raw[o + 14] & 0x0F) << 8) + fl = raw[o + 17] + t['interlaced'] = bool(fl & 0x80) + t['sync_type'] = SYNC_TYPES[(fl >> 2) & 3] + t['polarity'] = ('V+' if fl & 0x02 else 'V-') + ' ' + ('H+' if fl & 0x01 else 'H-') + t['htotal'] = t['hact'] + t['hblank'] + t['vtotal'] = t['vact'] + t['vblank'] + t['refresh'] = round(t['clk_khz'] * 1000.0 / (t['htotal'] * t['vtotal']), 1) + return t + + +def _ascii(raw, o, max_len): + out = [] + for i in range(max_len): + c = raw[o + i] + if c in (0x0A, 0x00): + break + if 32 <= c < 127: + out.append(chr(c)) + return ''.join(out) + + +def parse_edid(raw): + """解析 EDID(128 或 256 字节), 返回汇总 dict""" + e = {'raw': raw, 'dtds': [], 'est': [], 'std': [], 'cea_video': [], 'cea_blocks': []} + n = len(raw) + + e['cksum_base'] = (sum(raw[0:128]) & 0xFF) == 0 + + # 厂商 ID: 3 个 5bit 压缩大写字母 + b0, b1 = raw[8], raw[9] + e['manufacturer'] = ''.join([ + chr(((b0 & 0x7C) >> 2) + 64), + chr((((b0 & 0x03) << 3) | ((b1 & 0xE0) >> 5)) + 64), + chr((b1 & 0x1F) + 64)]) + e['product'] = struct.unpack_from('> 4) & 0x7] + e['input'] = '数字 / ' + INTERFACES[raw[20] & 0x7] + \ + (f' / {bpc}bpc' if bpc else ' / 色深未定义') + else: + e['input'] = '模拟 (VGA 输入)' + + e['wcm'], e['hcm'] = raw[21], raw[22] + e['gamma'] = 0 if raw[23] == 0xFF else (raw[23] + 100) / 100.0 + + feats = [] + if raw[24] & 0x80: feats.append('DPMS 待机') + if raw[24] & 0x40: feats.append('DPMS 挂起') + if raw[24] & 0x20: feats.append('DPMS 关闭') + if raw[24] & 0x04: feats.append('sRGB 特性') + if raw[24] & 0x02: feats.append('首选时序=原生') + e['features'] = ', '.join(feats) + + # 已建立时序位域 + for i in range(24): + bit = 7 - (i % 8) + if ((raw[35 + i // 8] >> bit) & 1) and EST_TABLE[i] != '-': + e['est'].append(EST_TABLE[i]) + + # 标准时序(8 组 2 字节, 0x0101 = 未用) + for i in range(38, 54, 2): + a, b = raw[i], raw[i + 1] + if (a == 1 and b == 1) or (a == 0 and b == 0): + continue + h = (a + 31) * 8 + v = round(h * STD_ASPECT[(a >> 6) & 3][0]) + e['std'].append(f'{h}x{v}@{b + 60}') + + # 基础块 4 个 18 字节描述符区: 前两字节非 0 即 DTD, 否则是监视器描述符 + e['name'] = e['serial_text'] = e['range_text'] = '' + for d in range(54, 126, 18): + if raw[d] == 0 and raw[d + 1] == 0: + tag = raw[d + 2] + if tag == 0xFC: + e['name'] = _ascii(raw, d + 5, 13) + elif tag == 0xFF: + e['serial_text'] = _ascii(raw, d + 5, 13) + elif tag == 0xFD: + e['range_text'] = (f"V {raw[d+5]}~{raw[d+6]}Hz, H {raw[d+7]}~{raw[d+8]}kHz, " + f"像素时钟上限 {raw[d+9] * 10}MHz") + else: + e['dtds'].append(parse_dtd(raw, d)) + + # CEA-861 扩展块 + e['has_ext'] = raw[126] > 0 and n >= 256 + e['has_cea'] = False + e['cea_rev'] = 0 + e['cea_caps'] = '' + e['cea_phys'] = '' + e['cksum_ext'] = False + if e['has_ext']: + e['cksum_ext'] = (sum(raw[128:256]) & 0xFF) == 0 + if raw[128] == 0x02: + e['has_cea'] = True + e['cea_rev'] = raw[129] + caps = [] + if raw[131] & 0x80: caps.append('欠扫描') + if raw[131] & 0x40: caps.append('基本音频') + if raw[131] & 0x20: caps.append('YCbCr4:4:4') + if raw[131] & 0x10: caps.append('YCbCr4:2:2') + e['cea_caps'] = ', '.join(caps) + + dtd_off = raw[130] + p = 132 + while dtd_off > 4 and (p - 128) < dtd_off and p + 1 < 256: + tag = (raw[p] >> 5) & 7 + length = raw[p] & 0x1F + if length == 0: + break + data = raw[p + 1:p + 1 + length] + if tag == 2: # 视频数据块: VIC 列表 + for k in range(length): + vic = data[k] & 0x7F + native = ' (原生)' if data[k] & 0x80 else '' + m = (VIC_TABLE[vic - 1] if 1 <= vic <= len(VIC_TABLE) else None) \ + or f'VIC#{vic}' + e['cea_video'].append(m + native) + elif tag == 1: # 音频数据块: N 个 3 字节 SAD + e['cea_blocks'].append(f'音频数据块 x{length // 3}') + elif tag == 4: + e['cea_blocks'].append('扬声器分配块') + elif tag == 5: + e['cea_blocks'].append('VESA DTC 块') + elif tag == 6: + e['cea_blocks'].append('视频格式块(VFDB)') + elif tag == 3: + # 厂商专用块: 前 3 字节 IEEE OUI(小端) + oui = bytes(data[0:3]) + if oui == b'\x03\x0c\x00': # HDMI 1.x VSDB: OUI 后 4 字节物理地址 + if length >= 7: + e['cea_phys'] = '.'.join(str(b) for b in data[3:7]) + e['cea_blocks'].append('HDMI VSDB') + elif oui == b'\xd8\x5d\xc4': # HDMI 2.0 HF-VSDB + e['cea_blocks'].append('HDMI 2.0 HF-VSDB') + else: + e['cea_blocks'].append(f'厂商块 OUI={oui.hex(" ")}') + elif tag == 7: + ext = data[0] if length >= 1 else -1 + name = EXT_TAG_NAMES.get(ext, f'0x{ext:02X}') + e['cea_blocks'].append(f'扩展块({name})') + else: + e['cea_blocks'].append(f'保留块 tag{tag}') + p += 1 + length + + if dtd_off >= 4: # 扩展块 DTD 区 + q = 128 + dtd_off + while q + 18 <= n: + if raw[q] == 0 and raw[q + 1] == 0: + break + e['dtds'].append(parse_dtd(raw, q)) + q += 18 + return e + + +# ============================================================ +# 文本化 +# ============================================================ +def edid_info_lines(e): + """[(项目, 值), ...] 摘要行""" + lines = [] + inch = 0 + if e['wcm'] > 0 and e['hcm'] > 0: + inch = round(math.hypot(e['wcm'], e['hcm']) / 2.54, 1) + lines.append(('厂商 / 产品码', f"{e['manufacturer']} / 0x{e['product']:04X}")) + lines.append(('显示器名称', e['name'] or '(未声明)')) + if e['serial_text']: + lines.append(('序列号(文本)', e['serial_text'])) + lines.append(('生产周 / 年', f"第 {e['week']} 周 / {e['year']} 年")) + lines.append(('EDID 版本', e['ver'])) + lines.append(('输入类型', e['input'])) + if inch > 0: + lines.append(('屏幕尺寸', f"{e['wcm']}cm x {e['hcm']}cm (约 {inch} 英寸)")) + if e['gamma'] > 0: + lines.append(('Gamma', e['gamma'])) + if e['features']: + lines.append(('特性', e['features'])) + if e['range_text']: + lines.append(('范围限制', e['range_text'])) + if e['has_ext']: + ck = 'OK' if e['cksum_ext'] else '错误!' + lines.append(('扩展块', f"CEA-861 rev.{e['cea_rev']} 校验和 {ck}")) + if e['cea_caps']: + lines.append(('CEA 能力', e['cea_caps'])) + if e['cea_phys']: + lines.append(('HDMI 物理地址', e['cea_phys'])) + else: + lines.append(('扩展块', '无')) + lines.append(('基础块校验和', 'OK (0x00)' if e['cksum_base'] else '错误!')) + + # 每个 DTD 的完整时序参数(硬件调试重点) + for k, t in enumerate(e['dtds']): + tag = 'DTD1(首选)' if k == 0 else f'DTD{k + 1}' + lines.append((f'{tag} 模式', f"{t['hact']}x{t['vact']}@{t['refresh']} {t['clk_khz'] / 1000:.3f} MHz")) + lines.append((f'{tag} 总时序', f"{t['htotal']}x{t['vtotal']}")) + lines.append((f'{tag} 水平', f"有效 {t['hact']} | 前沿 {t['hfront']} | 同步 {t['hsync']} | " + f"后沿 {t['htotal'] - t['hact'] - t['hfront'] - t['hsync']}")) + lines.append((f'{tag} 垂直', f"有效 {t['vact']} | 前沿 {t['vfront']} | 同步 {t['vsync']} | " + f"后沿 {t['vtotal'] - t['vact'] - t['vfront'] - t['vsync']}")) + lines.append((f'{tag} 同步', f"{t['sync_type']} {t['polarity']} " + f"{'(隔行)' if t['interlaced'] else '(逐行)'}")) + if t['him_mm'] > 0: + lines.append((f'{tag} 图像尺寸', f"{t['him_mm']}x{t['vim_mm']} mm")) + return lines + + +def hex_dump(b): + out = [] + for row in range((len(b) + 15) // 16): + off = row * 16 + chunk = b[off:off + 16] + # 8+8 分组, 组间双空格: 同步头/厂商段一眼分开 + hx = ' '.join(f'{v:02X}' for v in chunk[:8]) + ' ' + \ + ' '.join(f'{v:02X}' for v in chunk[8:]) + asc = ''.join(chr(v) if 32 <= v < 127 else '.' for v in chunk) + out.append(f'{off:04X}: {hx:<50} {asc}') + return '\n'.join(out) + + +# ============================================================ +# EDID 逐字节字段解析(调试视图): 基础块 + CTA-861 扩展块 +# 每个字段一行: [偏移] 原始字节 | 含义 | 解码值 | 备注 +# ============================================================ +B_HEAD = '同步头 00 FF FF FF FF FF FF 00' + + +def _range_limits(d): + """FD 频率范围描述符(18 字节常规布局) -> V/H 范围与像素时钟上限""" + try: + return (f"V {d[5]}~{d[6]}Hz, H {d[7]}~{d[8]}kHz, " + f"像素时钟上限 {d[9] * 10}MHz") + except (IndexError, ValueError): + return '' + + +def _bits(b, on, off=None): + return f'{b:02X}: ' + ('; '.join(on) if on else (off or '无置位')) + + +def edid_field_rows(raw): + """把 EDID(128/256 字节)逐字段解成 [(偏移, 原始字节, 含义, 解码值, 备注), ...]""" + rows = [] + n = len(raw) + + def add(off, ln, meaning, val, note=''): + hx = raw[off:off + ln].hex(' ').upper() + if ln > 18: # 超长区域(如零填充)截断, 完整数据看 hex dump + hx = raw[off:off + 18].hex(' ').upper() + f' …(共 {ln} 字节)' + rows.append((f'0x{off:02X}-{off + ln - 1:02X}' if ln > 1 else f'0x{off:02X}', + hx, meaning, str(val), note)) + + add(0, 8, B_HEAD, 'OK' if raw[0:8] == bytes.fromhex('00FFFFFFFFFFFF00') else '不匹配!', + '不许改, 用于同步识别') + # 8-9 厂商 ID: 16 位大端, 3 组 5bit 压缩大写字母(块内唯一大端字段) + mfr = ''.join([chr(((raw[8] & 0x7C) >> 2) + 64), + chr((((raw[8] & 0x03) << 3) | ((raw[9] & 0xE0) >> 5)) + 64), + chr((raw[9] & 0x1F) + 64)]) + add(8, 2, '厂商 ID(大端)', mfr, + f"3×5bit 压缩字母, 原始 0x{(raw[8] << 8) | raw[9]:04X}; 正式产品应使用注册的 PNP ID") + add(10, 2, '产品码(小端)', f"0x{struct.unpack_from('> 4) & 0x7] + val = f'数字 / {itf}' + (f' / {bpc}bpc' if bpc else ' / 色深未定义') + else: + val = '模拟输入' + add(20, 1, '输入类型', val, + 'bit7=1 数字; 低 4 位: 1=HDMI-A 2=HDMI-B 4=DP 5=eDP') + + add(21, 1, '最大画面宽(cm)', raw[21], '0 = 未知(横竖比由 DTD 图像尺寸给出)') + add(22, 1, '最大画面高(cm)', raw[22], '0 = 未知') + add(23, 1, 'Gamma', f"{(raw[23] + 100) / 100:.2f}" if raw[23] else '(未提供)', + '存储值 = gamma×100 − 100(如 0x78 → 2.20); 0xFF = 不提供') + add(24, 1, '特性位', _bits(raw[24], [ + *(['DPMS 待机'] if raw[24] & 0x80 else []), + *(['DPMS 挂起'] if raw[24] & 0x40 else []), + *(['DPMS 关闭'] if raw[24] & 0x20 else []), + *([f"显示色彩 {['RGB 4:4:4', 'RGB+YCrCb 4:4:4', 'RGB+YCrCb 4:2:2', '未定义'][(raw[24] >> 3) & 3]}"] + if raw[24] & 0x80 else []), + *(['sRGB 默认'] if raw[24] & 0x04 else []), + *(['首选时序=原生分辨率'] if raw[24] & 0x02 else []), + *(['连续频率/GTF'] if raw[24] & 0x01 else []), + ]), 'bit2 sRGB / bit1 首选时序有效 / bit0 连续频率') + add(25, 10, '色度坐标(CIE xy)', '默认 sRGB 模板' if raw[25:35] == bytes.fromhex( + '78EE95A3554D9EA6544C') else '(打包位段)', '红绿蓝白四点 xy 各 10bit 打包, 通常照抄模板') + est = [] + est_bits = 0 + for i in range(24): + bit = 7 - (i % 8) + if (raw[35 + i // 8] >> bit) & 1: + est_bits |= 1 << (23 - i) + if EST_TABLE[i] != '-': + est.append(EST_TABLE[i]) + if est: + add(35, 3, 'Established Timing 位图', ', '.join(est), + 'VESA 老 VGA 时序; 数字屏常写 FF FF 00=全支持') + else: + add(35, 3, 'Established Timing 位图', + '无有效声明' + ('(仅保留位置位)' if est_bits else ''), + 'VESA 老 VGA 时序; 数字屏常写 FF FF 00=全支持') + stds = [] + for i in range(38, 54, 2): + a, b = raw[i], raw[i + 1] + if (a == 1 and b == 1) or (a == 0 and b == 0): + continue + h = (a + 31) * 8 + ar = STD_ASPECT[(a >> 6) & 3][1] + stds.append(f"{h}x{round(h * STD_ASPECT[(a >> 6) & 3][0])}@{b + 60} {ar}") + add(38, 16, '标准时序 ×8', '; '.join(stds) if stds else '8 槽全部未用(01 01)', + '2 字节/槽: 6bit 宽 + 2bit 比例 + 刷新率−60; 未用槽填 01 01') + + # 0x36-0x7D: 4 × 18 字节描述符 + for k, d in enumerate(range(54, 126, 18)): + grp = raw[d:d + 18] + if grp[0] or grp[1]: + t = parse_dtd(raw, d) + note = (f"{'(隔行)' if t['interlaced'] else '(逐行)'} " + f"{t['sync_type']} {'/'.join(t['polarity'].split())}; " + f"消隐 H{t['hblank']}/V{t['vblank']}, " + f"前/同/后 H{t['hfront']}/{t['hsync']}/{t['htotal'] - t['hact'] - t['hfront'] - t['hsync']} " + f"V{t['vfront']}/{t['vsync']}/{t['vtotal'] - t['vact'] - t['vfront'] - t['vsync']}") + add(d, 18, f'描述符{k + 1}: DTD(详细时序)', + f"{t['hact']}×{t['vact']}@{t['refresh']}Hz " + f"总 {t['htotal']}×{t['vtotal']} {t['clk_khz'] / 1000:.3f}MHz" + + (' [首选时序]' if k == 0 else ''), + note + ('; 图像尺寸 %dx%d mm' % (t['him_mm'], t['vim_mm']) if t['him_mm'] else '')) + else: + tag = grp[3] + txt = _ascii(raw, d + 5, 13) + if tag == 0xFF: + add(d, 18, f'描述符{k + 1}: 序列号串(0xFF)', repr(txt), 'ASCII, 上限 13 字节') + elif tag == 0xFC: + add(d, 18, f'描述符{k + 1}: 显示器名(0xFC)', repr(txt), 'ASCII, 上限 13 字节') + elif tag == 0xFD: + add(d, 18, f'描述符{k + 1}: 范围限制(0xFD)', _range_limits(grp), '频率范围描述符') + elif tag == 0xFE: + add(d, 18, f'描述符{k + 1}: 无界文本(0xFE)', repr(txt), '') + elif tag == 0x10: + add(d, 18, f'描述符{k + 1}: 数据串(0x10)', grp[5:18].hex(' ').upper(), '二进制用途') + elif tag == 0 and not any(grp): + add(d, 18, f'描述符{k + 1}: 空', '(全 00)', '') + else: + add(d, 18, f'描述符{k + 1}: 未知类型 0x{tag:02X}', grp[5:18].hex(' ').upper(), '') + + add(126, 1, '扩展块数量', raw[126], '必须与实际扩展块数一致') + cksum = (256 - sum(raw[0:127])) & 0xFF + add(127, 1, '基础块校验和', f"0x{raw[127]:02X} → " + ('正确' if (sum(raw[0:128]) & 0xFF) == 0 else '错误!'), + f"整块 128 字节和 ≡ 0 (mod 256); 期望 0x{cksum:02X} = 256 − 前 127 字节和") + + # ---- CTA-861 扩展块 ---- + n_ext = raw[126] if n > 126 else 0 + for bi in range(n_ext): + base = 128 + bi * 128 + if base + 128 > n: + add(base, 0, f'扩展块{bi + 1}', '(数据不完整)', '') + break + blk = raw[base:base + 128] + add(base, 1, f'扩展块{bi + 1} @0x{base:02X} Tag', + 'CTA-861' if blk[0] == 0x02 else f'0x{blk[0]:02X}(非 CTA)', + '固定 0x02 = CTA 扩展; HDMI 必须有') + if blk[0] != 0x02: + add(base + 1, 127, '扩展块数据', blk[1:128].hex(' ').upper(), '未知扩展类型, 跳过') + continue + add(base + 1, 1, '版本', f"rev {blk[1]}", '常用 03') + add(base + 2, 1, 'DTD 起始偏移', f"0x{blk[2]:02X} (= 块内 {blk[2]} 字节处)", '数据块链结束、DTD 区开始的偏移') + add(base + 3, 1, '标志位', _bits(blk[3], [ + *(['bit7 支持 YCbCr 4:2:0'] if blk[3] & 0x80 else []), + *(['bit6 支持基础音频'] if blk[3] & 0x40 else []), + *(['bit5 支持 YCbCr 4:2:2'] if blk[3] & 0x20 else []), + *(['bit4 支持 YCbCr 4:4:4'] if blk[3] & 0x10 else []), + *(['bit0 支持欠扫描'] if blk[3] & 0x01 else []), + ]), 'bit6=基础音频, bit5=4:2:2, bit7=4:2:0, bit0=欠扫描') + + # 数据块链: 每块 1 字节头(高 3 位 tag + 低 5 位长度) + payload + TAG_NAMES = {1: '音频', 2: '视频 VIC', 3: '厂商专用 VSDB', 4: '扬声器分配', + 5: 'VESA DTC', 6: '视频格式 VFDB', 7: '扩展 tag'} + p, first = 4, True + while base + p < base + 128 and p < blk[2] and p + 1 < 128: + tag, length = (blk[p] >> 5) & 7, blk[p] & 0x1F + payload = blk[p + 1:p + 1 + length] + if first and blk[p] == 0: + add(base + p, 1, '数据块链', '(无数据块, 直接进 DTD 区)', '4 至 DTD 区前为数据块链') + break + first = False + if tag == 2: + vics = [] + for v in payload: + vi = v & 0x7F + name = (VIC_TABLE[vi - 1] if 1 <= vi <= len(VIC_TABLE) else None) or f'VIC#{vi}' + vics.append(name + ('(原生)' if v & 0x80 else '')) + add(base + p, 1 + length, '数据块: 视频', ' '.join(vics) or '(空)', f"tag 2, {length} 字节") + elif tag == 1: + fmts = [] + codes = ['保留', 'LPCM', 'AAC', 'AC-3', 'MPEG1', 'MP3', 'MPEG2', 'AAC LC', + 'DTS', 'ATRAC', 'DSD', 'E-AC-3', 'DTS-HD', 'MLP', 'DST', 'WMAPro'] + for si in range(length // 3): + sad = payload[si * 3:(si + 1) * 3] + ch = (sad[0] & 7) + 1 + rates = '/'.join(str(r) for r, b in ((32, 1), (44, 2), (48, 4)) if sad[2] & b) or '?' + mults = ''.join(f'×{m}' for m, b in ((2, 8), (3, 16), (4, 32)) if sad[2] & b) + if (sad[0] >> 3) & 0xF == 1: # LPCM: 位宽在字节1低3位(位图) + bits = '/'.join(str(v) for v, b in ((16, 1), (20, 2), (24, 4)) if sad[1] & b) or '?' + fmts.append(f'LPCM {ch}ch {bits}bit {rates}kHz{mults}') + else: # 压缩格式: 字节1高4位=最高采样率档 + fmts.append(f"{codes[(sad[0] >> 3) & 0xF]} {ch}ch {rates}kHz{mults}") + add(base + p, 1 + length, '数据块: 音频', '; '.join(fmts), f"tag 1, {length // 3} × 3 字节 SAD") + elif tag == 3: + oui = bytes(payload[0:3]) + names = {b'\x03\x0c\x00': 'HDMI 1.x VSDB', b'\xd8\x5d\xc4': 'HDMI 2.0 HF-VSDB'} + val = names.get(oui, f'OUI={oui.hex(" ").upper()}') + if oui == b'\x03\x0c\x00' and length >= 7: + val += f", 物理地址 {'.'.join(str(b) for b in payload[3:7])}" + add(base + p, 1 + length, '数据块: 厂商专用', val, + f"tag 3, 头 3 字节 IEEE OUI(小端){'; HDMI 2.0 需 HF-VSDB' if oui == b'\x03\x0c\x00' else ''}") + else: + name = TAG_NAMES.get(tag, f'tag{tag}') + add(base + p, 1 + length, f'数据块: {name}', + payload.hex(' ').upper() if length else '(空)', f"{length} 字节 payload") + if length == 0: + break + p += 1 + length + + # DTD 区 + if blk[2] >= 4: + q, cnt = blk[2], 0 + while q + 18 <= 128 and any(blk[q:q + 2]): + t = parse_dtd(blk, q) + add(base + q, 18, f'扩展块 DTD{cnt + 1}', + f"{t['hact']}×{t['vact']}@{t['refresh']}Hz 总 {t['htotal']}×{t['vtotal']} " + f"{t['clk_khz'] / 1000:.3f}MHz", + "前/同/后 H%d/%d/%d V%d/%d/%d %s" % ( + t['hfront'], t['hsync'], t['htotal'] - t['hact'] - t['hfront'] - t['hsync'], + t['vfront'], t['vsync'], t['vtotal'] - t['vact'] - t['vfront'] - t['vsync'], + '(隔行)' if t['interlaced'] else '(逐行)')) + cnt += 1 + q += 18 + if not cnt: + add(base + blk[2], 0, '扩展块 DTD 区', '(空)', '未用空间填 0') + rem = 128 - max(blk[2], 4) - 18 * cnt if blk[2] >= 4 else 0 + if blk[2] >= 4 and rem > 0 and not any(blk[128 - rem:128 - 1]): + add(base + 128 - rem, rem - 1, '未用空间', '0 填充', '') + add(base + 127, 1, f'扩展块{bi + 1} 校验和', + f"0x{blk[127]:02X} → " + ('正确' if (sum(blk) & 0xFF) == 0 else '错误!'), + f"期望 0x{(256 - sum(blk[0:127])) & 0xFF:02X} (同基础块规则)") + return rows + + +def edid_field_text(raw): + """逐字段解析 -> 多行文本(命令行 / 文本框共用)""" + w = (7, 26, 20) + out = ['%-*s %-*s %-*s %s' % (w[0], '偏移', w[1], '含义', w[2], '解码值', '备注 / 原始字节')] + for off, hx, meaning, val, note in edid_field_rows(raw): + line = '%-*s %-*s %-*s %s' % (w[0], off, w[1], meaning, w[2], val, note) + out.append(line.rstrip()) + if hx: + out.append('%-*s %-*s %s %s' % (w[0], '', w[1], '', '原始', hx)) + return '\n'.join(out) diff --git a/HDMI-Tool/src/hdmi_gui.py b/HDMI-Tool/src/hdmi_gui.py index c4906df..8da36a3 100644 --- a/HDMI-Tool/src/hdmi_gui.py +++ b/HDMI-Tool/src/hdmi_gui.py @@ -1,23 +1,20 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -HDMI EDID 查看器 + 测试图案投屏工具 +HDMI EDID 查看器 + 测试图案投屏 + 光机控制 — 入口与命令行模式 ================================================ 配合 LT8619C(HDMI 转 RGB888)→ DLPC3421 转接板调试使用。 -功能: - 1. 枚举各显示输出口, 从注册表读取其 EDID 原始数据, - 按 EDID 1.4 / CEA-861 标准解算: 厂商/产品/名称/尺寸/Gamma/输入类型、 - 支持的全部模式(详细时序 DTD / 已建立时序 / 标准时序 / CEA VIC)、 - 首选时序完整参数(像素时钟/消隐/同步极性)、CEA 扩展能力、校验和。 - 2. 向所选屏幕全屏投测试图案(黑底 1:1 居中, 测试卡区域可选 - 640x360 / 800x600 / full 铺满): 纯红 / 纯绿 / 纯蓝 / 黑白棋盘格 / - 网格线 + 3 个自定义图片栏位, 点击或 ESC 退出。 - 3. 光机控制: RS485 直连板上 GD32E230 网关(→DLPC3421), 开关光机/设光强/ - 查温度电流/定时投光, 协议见 dlpc_rs485.py 头注释(上游仓 protocols.md)。 +模块划分(重构自原单文件): + app_gui.py CustomTkinter 主界面(双栏仪表盘) + display_win.py Win32 显示器枚举/分辨率/拓扑 + edid_parse.py EDID 1.4 / CEA-861 解析 + patterns.py 测试图案全屏投屏窗口 + config.py 运行目录与 pattern_slots.json 配置 + dlpc_rs485.py 光机 RS485 协议与串口链路 -仅用标准库(ctypes / winreg / tkinter), 无需 pip 安装任何依赖; -可选 pyserial(光机控制)与 Pillow(自定义图片 JPG/BMP/WebP), 缺了不影响其余功能。 +依赖: CLI/EDID 纯标准库; GUI 需 customtkinter(py -m pip install customtkinter); +可选 pyserial(光机控制)与 Pillow(自定义图片 JPG/BMP/WebP)。 命令行模式(自动化测试用): python hdmi_gui.py 启动 GUI @@ -33,1572 +30,26 @@ HDMI EDID 查看器 + 测试图案投屏工具 """ import argparse -import ctypes -import ctypes.wintypes as wt -import json -import math import os -import queue -import shutil -import struct import sys import time -import winreg import dlpc_rs485 +import display_win +import edid_parse +import patterns +from config import (AREA_OPTIONS, BASE_DIR, DEFAULT_AREA, SLOT_COUNT, load_slots) +from display_win import ensure_display_extend, get_displays +from edid_parse import edid_field_text, edid_info_lines, hex_dump, parse_edid +from patterns import PATTERN_NAMES, show_pattern, show_pattern_image -# 进程级 DPI 感知: 保证图案窗口按物理像素精确定位/绘制 -try: - ctypes.windll.shcore.SetProcessDpiAwareness(2) -except Exception: - pass -# 可选 Pillow: 装了则支持 JPEG/BMP/WebP, 没装则用 tkinter 原生(仅 PNG/GIF) -try: - from PIL import Image, ImageTk - HAS_PIL = True -except ImportError: - HAS_PIL = False - -# ============================================================ -# 自定义图案栏位: 配置存于脚本目录 pattern_slots.json, -# 图片文件同样保存在脚本目录下 -# 注: PyInstaller onefile 打包后 __file__ 指向临时解压目录, -# 配置必须落在 exe 旁边, 故 frozen 时改用 sys.executable 定位 -# ============================================================ -if getattr(sys, 'frozen', False): - BASE_DIR = os.path.dirname(os.path.abspath(sys.executable)) -else: - BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -SLOT_COUNT = 3 -SLOT_CONFIG = os.path.join(BASE_DIR, 'pattern_slots.json') -IMG_EXTS = ['.png', '.gif'] + (['.jpg', '.jpeg', '.bmp', '.webp'] if HAS_PIL else []) - -# 光机控制串口记忆(与栏位配置同存 pattern_slots.json 的 "serial" 键) -SERIAL_CFG = {'port': '', 'baud': dlpc_rs485.DEFAULT_BAUD} - - -def load_slots(): - """读 pattern_slots.json, 返回 (slots, area)。 - - 新格式为 {'area': 'WxH'|'full', 'slots': [...], 'serial': {...}}; - 兼容旧版纯数组格式(无 area)与无 serial 键的中间格式。 - """ - slots = [{'name': f'自定义{i + 1}', 'file': None} for i in range(SLOT_COUNT)] - try: - with open(SLOT_CONFIG, encoding='utf-8') as f: - data = json.load(f) - except Exception: - return slots, DEFAULT_AREA - if isinstance(data, dict): - area = str(data.get('area') or DEFAULT_AREA) - items = data.get('slots') or [] - ser = data.get('serial') - if isinstance(ser, dict): - SERIAL_CFG['port'] = str(ser.get('port') or '') - try: - SERIAL_CFG['baud'] = int(ser.get('baud') or dlpc_rs485.DEFAULT_BAUD) - except (TypeError, ValueError): - pass - else: - area, items = DEFAULT_AREA, data - for i, item in enumerate(items[:SLOT_COUNT]): - if isinstance(item, dict): - slots[i]['name'] = str(item.get('name') or slots[i]['name']) - slots[i]['file'] = item.get('file') or None - return slots, area - - -def save_slots(slots, area): - with open(SLOT_CONFIG, 'w', encoding='utf-8') as f: - json.dump({'area': area, 'slots': slots, 'serial': SERIAL_CFG}, - f, ensure_ascii=False, indent=2) - - -def copy_image_into_script_dir(src_path): - """把任意路径的图片复制到脚本目录, 重名自动加序号, 返回文件名""" - dst_name = os.path.basename(src_path) - dst = os.path.join(BASE_DIR, dst_name) - if os.path.normcase(os.path.abspath(src_path)) == os.path.normcase(dst): - return dst_name - stem, ext = os.path.splitext(dst_name) - n = 1 - while os.path.exists(dst): - dst_name = f'{stem}_{n}{ext}' - dst = os.path.join(BASE_DIR, dst_name) - n += 1 - shutil.copy2(src_path, dst) - return dst_name - - -def load_image_raw(path): - """按原始像素加载图片(不缩放不裁剪; PIL 仅用于支持 JPG/BMP/WebP 等格式)""" - import tkinter as tk - if HAS_PIL: - return ImageTk.PhotoImage(Image.open(path).convert('RGB')) - return tk.PhotoImage(file=path) - - -def get_icon_path(): - """工具图标: 源码模式取 logos/ 下, 打包模式取 exe 内嵌资源(_MEIPASS)""" - if getattr(sys, 'frozen', False): - p = os.path.join(getattr(sys, '_MEIPASS', ''), 'logo_ph_orange.ico') - else: - p = os.path.join(BASE_DIR, 'logos', 'logo_ph_orange.ico') - return p if os.path.exists(p) else None - - -# ============================================================ -# Win32 显示 API -# ============================================================ -user32 = ctypes.windll.user32 - -ENUM_CURRENT_SETTINGS = 0xFFFFFFFF # (DWORD)-1 - - -class DISPLAY_DEVICEW(ctypes.Structure): - _fields_ = [ - ('cb', wt.DWORD), - ('DeviceName', ctypes.c_wchar * 32), - ('DeviceString', ctypes.c_wchar * 128), - ('StateFlags', wt.DWORD), - ('DeviceID', ctypes.c_wchar * 128), - ('DeviceKey', ctypes.c_wchar * 128), - ] - - -class DEVMODEW(ctypes.Structure): - _fields_ = [ - ('dmDeviceName', ctypes.c_wchar * 32), - ('dmSpecVersion', wt.WORD), - ('dmDriverVersion', wt.WORD), - ('dmSize', wt.WORD), - ('dmDriverExtra', wt.WORD), - ('dmFields', wt.DWORD), - ('dmPositionX', wt.LONG), - ('dmPositionY', wt.LONG), - ('dmDisplayOrientation', wt.DWORD), - ('dmDisplayFixedOutput', wt.DWORD), - ('dmColor', wt.WORD), - ('dmDuplex', wt.WORD), - ('dmYResolution', wt.WORD), - ('dmTTOption', wt.WORD), - ('dmCollate', wt.WORD), - ('dmFormName', ctypes.c_wchar * 32), - ('dmLogPixels', wt.WORD), - ('dmBitsPerPel', wt.DWORD), - ('dmPelsWidth', wt.DWORD), - ('dmPelsHeight', wt.DWORD), - ('dmDisplayFlags', wt.DWORD), - ('dmDisplayFrequency', wt.DWORD), - ('dmICMMethod', wt.DWORD), - ('dmICMIntent', wt.DWORD), - ('dmMediaType', wt.DWORD), - ('dmDitherType', wt.DWORD), - ('dmReserved1', wt.DWORD), - ('dmReserved2', wt.DWORD), - ('dmPanningWidth', wt.DWORD), - ('dmPanningHeight', wt.DWORD), - ] - - -user32.EnumDisplayDevicesW.argtypes = [ctypes.c_wchar_p, wt.DWORD, - ctypes.POINTER(DISPLAY_DEVICEW), wt.DWORD] -user32.EnumDisplayDevicesW.restype = wt.BOOL -user32.EnumDisplaySettingsW.argtypes = [ctypes.c_wchar_p, wt.DWORD, - ctypes.POINTER(DEVMODEW)] -user32.EnumDisplaySettingsW.restype = wt.BOOL -user32.ChangeDisplaySettingsExW.argtypes = [ctypes.c_wchar_p, ctypes.POINTER(DEVMODEW), - ctypes.c_void_p, wt.DWORD, ctypes.c_void_p] -user32.ChangeDisplaySettingsExW.restype = ctypes.c_long - -DM_BITSPERPEL = 0x00040000 -DM_PELSWIDTH = 0x00080000 -DM_PELSHEIGHT = 0x00100000 -DM_DISPLAYFREQUENCY = 0x00400000 - - -def read_edid_from_registry(device_id): - """monitor DeviceID 'MONITOR\\\\' → 注册表 EDID 原始字节 - - 注意: 间接显示器/虚拟显示驱动返回的 DeviceID 实例段是 '{类GUID}\\NNNN' - 格式, 注册表中并不存在该路径; 因此精确匹配失败后按 PNP ID 枚举其下 - 全部实例, 取第一份 EDID(同一物理屏的重复实例 EDID 相同)。 - """ - if not device_id or not device_id.startswith('MONITOR\\'): - return None - parts = device_id.split('\\') - if len(parts) < 3: - return None - pnp = parts[1] - inst = device_id.split('\\', 2)[2] - - def _read(path): - try: - key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path) - try: - val, _typ = winreg.QueryValueEx(key, 'EDID') - if isinstance(val, (bytes, bytearray)): - return bytes(val) - if isinstance(val, list): # 某些系统返回 int 列表 - return bytes(val) - finally: - winreg.CloseKey(key) - except OSError: - pass - return None - - # 1) 精确实例路径(常规真实显示器) - raw = _read(rf'SYSTEM\CurrentControlSet\Enum\DISPLAY\{pnp}\{inst}\Device Parameters') - if raw: - return raw - - # 2) 按 PNP ID 遍历全部实例(间接显示器/实例名不一致) - try: - pnp_root = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, - rf'SYSTEM\CurrentControlSet\Enum\DISPLAY\{pnp}') - except OSError: - return None - try: - for i in range(winreg.QueryInfoKey(pnp_root)[0]): - sub = winreg.EnumKey(pnp_root, i) - raw = _read(rf'SYSTEM\CurrentControlSet\Enum\DISPLAY\{pnp}\{sub}\Device Parameters') - if raw: - return raw - finally: - winreg.CloseKey(pnp_root) - return None - - -def get_displays(): - """枚举活动显示器: 设备名/显卡/监视器名/当前模式/屏幕矩形/EDID 字节""" - out = [] - i = 0 - while True: - dd = DISPLAY_DEVICEW() - dd.cb = ctypes.sizeof(dd) - if not user32.EnumDisplayDevicesW(None, i, ctypes.byref(dd), 0): - break - if dd.StateFlags & 0x1: # ATTACHED_TO_DESKTOP - # 找该输出口上活动的 monitor 条目 - mon = None - j = 0 - while True: - m = DISPLAY_DEVICEW() - m.cb = ctypes.sizeof(m) - if not user32.EnumDisplayDevicesW(dd.DeviceName, j, ctypes.byref(m), 0): - break - if m.StateFlags & 0x1: - mon = m - break - j += 1 - - cur = '' - rect = None - dm = DEVMODEW() - dm.dmSize = ctypes.sizeof(dm) - if user32.EnumDisplaySettingsW(dd.DeviceName, ENUM_CURRENT_SETTINGS, - ctypes.byref(dm)): - cur = f'{dm.dmPelsWidth}x{dm.dmPelsHeight}@{dm.dmDisplayFrequency}' - rect = (dm.dmPositionX, dm.dmPositionY, dm.dmPelsWidth, dm.dmPelsHeight) - - out.append({ - 'device': dd.DeviceName, # \\.\DISPLAY1 - 'adapter': dd.DeviceString, # 显卡名 - 'mon_name': mon.DeviceString if mon else '', - 'edid': read_edid_from_registry(mon.DeviceID) if mon else None, - 'cur_mode': cur, - 'rect': rect, # (x, y, w, h) 物理像素 - }) - i += 1 - return out - - -def enum_modes(device): - """枚举 device(\\\\.\\DISPLAYn) 可用模式 —— 显卡按该屏 EDID 提供, - 返回 (['WxH@Hz', ...] 按像素数降序, {'WxH@Hz': DEVMODEW})""" - seen = {} - i = 0 - while True: - dm = DEVMODEW() - dm.dmSize = ctypes.sizeof(dm) - if not user32.EnumDisplaySettingsW(device, i, ctypes.byref(dm)): - break - i += 1 - if dm.dmBitsPerPel != 32: - continue - key = f'{dm.dmPelsWidth}x{dm.dmPelsHeight}@{dm.dmDisplayFrequency}' - seen.setdefault(key, dm) - keys = sorted(seen, key=lambda k: tuple(int(v) for v in k.replace('@', 'x').split('x')), - reverse=True) - return keys, seen - - -def apply_mode(device, dm): - """把 device 切到 dm 模式(临时切换, 不写注册表, 重启还原), 返回 (成功?, 返回码)""" - dm.dmFields = DM_BITSPERPEL | DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY - r = user32.ChangeDisplaySettingsExW(device, ctypes.byref(dm), None, 0, None) - return r == 0, r # DISP_CHANGE_SUCCESSFUL == 0 - - -def ensure_display_extend(): - """仅当转接板显示器"已接入但未进入桌面"时, 才强制扩展拓扑。 - - 场景: 板卡热插拔后, 若 Windows 停留在"仅电脑屏幕"拓扑(会记住上次 - Win+P 的选择), 板卡的 EDID 虽已安装、GPU 也在按首选时序输出, 但桌面 - 并未扩展过去, get_displays() 就枚举不到这块"显示器" —— 此时才需要 - 强制扩展把它拉回桌面。 - - 用户主动选择的拓扑(家里的"仅第二屏幕"、手动关闭的显示器等)一律不动: - 枚举"接入但不在桌面"的显示器, 只有其 EDID 厂商是转接板签名 "XLS" - (字节 0x61 0x93, 上游 lt8619c_edid.c) 时才扩展; 笔记本内屏等其它 - 厂商视为用户有意关闭, 不干预。 - - 返回 SetDisplayConfig 的返回码(0=成功, -1=未触发, 其它=异常); 静默容错。 - """ - try: - board_stuck = False - i = 0 - while True: - dd = DISPLAY_DEVICEW() - dd.cb = ctypes.sizeof(dd) - if not user32.EnumDisplayDevicesW(None, i, ctypes.byref(dd), 0): - break - i += 1 - j = 0 - while True: - m = DISPLAY_DEVICEW() - m.cb = ctypes.sizeof(m) - if not user32.EnumDisplayDevicesW(dd.DeviceName, j, ctypes.byref(m), 0): - break - j += 1 - if m.StateFlags & 0x1: # ATTACHED_TO_DESKTOP: 已在桌面 - continue - raw = read_edid_from_registry(m.DeviceID) - if len(raw or ()) >= 10 and raw[8:10] == b'\x61\x93': - board_stuck = True # 转接板没进桌面 -> 需要拉回 - if not board_stuck: - return -1 - SDC_TOPOLOGY_EXTEND = 0x00000004 - SDC_APPLY = 0x00000080 - return ctypes.windll.user32.SetDisplayConfig( - 0, None, 0, None, SDC_TOPOLOGY_EXTEND | SDC_APPLY) - except Exception: - return -1 - - -# ============================================================ -# EDID 1.4 / CEA-861 解析 -# ============================================================ -# CTA-861 VIC 1..64 常用映射(65+ 及扩展 VIC 不展开) -VIC_TABLE = [ - '640x480@60', '720x480@60 4:3', '720x480@60 16:9', '1280x720@60', '1920x1080i@60', - '720x480i@60 4:3', '720x480i@60 16:9', '720x240p@60 4:3', '720x240p@60 16:9', - '1440x480i@60 4:3', '1440x480i@60 16:9', '1440x240p@60 4:3', '1440x240p@60 16:9', - '1440x480p@60 4:3', '1440x480p@60 16:9', '1920x1080p@60', '720x576p@50 4:3', '720x576p@50 16:9', - '1280x720p@50', '1920x1080i@50', '720x576i@50 4:3', '720x576i@50 16:9', - '720x288p@50 4:3', '720x288p@50 16:9', - '1440x576i@50 4:3', '1440x576i@50 16:9', '1440x288p@50 4:3', '1440x288p@50 16:9', - '1440x576p@50 4:3', '1440x576p@50 16:9', '1920x1080p@50', '1920x1080p@24', - '1920x1080p@25', '1920x1080p@30', - '2880x480p@60 4:3', '2880x480p@60 16:9', '2880x576p@50 4:3', '2880x576p@50 16:9', - '1920x1080i@50(1250)', '1920x1080i@100', '1280x720p@100', - '720x576p@100 4:3', '720x576p@100 16:9', '720x576i@100 4:3', '720x576i@100 16:9', - '1920x1080i@120', '1280x720p@120', - '720x480p@120 4:3', '720x480p@120 16:9', '720x480i@120 4:3', '720x480i@120 16:9', - '720x576p@200 4:3', '720x576p@200 16:9', '720x576i@200 4:3', '720x576i@200 16:9', - '720x480p@240 4:3', '720x480p@240 16:9', '720x480i@240 4:3', '720x480i@240 16:9', - '1280x720p@24', '1280x720p@25', '1280x720p@30', '1920x1080p@120', '1920x1080p@240', - # VIC 65-88 为 3D/特殊格式, 少见不展开 - *[None] * 24, - '2560x1080p@50', '2560x1080p@60', '2560x1080p@100', '2560x1080p@120', - '3840x2160p@24', '3840x2160p@25', '3840x2160p@30', - '3840x2160p@50', '3840x2160p@60', '4096x2160p@24', -] - -# 已建立时序位域表(bytes 35-37, 共 24 位, '-' 为保留位) -EST_TABLE = [ - '720x400@70', '720x400@88', '640x480@60', '640x480@67', '640x480@72', '640x480@75', - '800x600@56', '800x600@60', - '800x600@72', '800x600@75', '832x624@75', '1024x768@87i', '1024x768@60', '1024x768@70', - '1024x768@75', '1280x1024@75', - '1152x870@75', '-', '-', '-', '-', '-', '-', '-', -] - -STD_ASPECT = {0: (10 / 16, '16:10'), 1: (3 / 4, '4:3'), 2: (4 / 5, '5:4'), 3: (9 / 16, '16:9')} -DEPTHS = [0, 6, 8, 10, 12, 14, 16, 0] -INTERFACES = ['未定义', 'DVI', 'HDMI-A', 'HDMI-B', 'MDDI', 'DisplayPort', '保留', '保留'] -SYNC_TYPES = ['模拟合成', '双极模拟合成', '数字合成', '数字分离'] - -# CEA-861 数据块 tag(CTA-861 标准): 1=音频 2=视频 3=厂商专用(VSDB) -# 4=扬声器分配 5=VESA DTC 6=视频格式 7=扩展 tag 集合 -EXT_TAG_NAMES = { - 0x00: '视频能力', 0x01: '厂商扩展', 0x02: 'VESA 显示设备', 0x04: 'HDMI 4K/8K 视频格式', - 0x05: '色度学', 0x06: 'HDR 静态元数据', 0x07: 'HDR 动态元数据', 0x0C: 'YCbCr4:2:0 能力', - 0x0E: 'YCbCr4:2:0 视频格式', 0x78: 'HDMI 论坛 SCDB', -} - - -def parse_dtd(raw, o): - """解析 18 字节详细时序描述符, 返回 dict""" - t = {} - t['clk_khz'] = struct.unpack_from('> 4) | ((raw[o + 11] & 0x0C) << 2) - t['vsync'] = (raw[o + 10] & 0x0F) | ((raw[o + 11] & 0x03) << 4) - t['him_mm'] = raw[o + 12] | ((raw[o + 14] & 0xF0) << 4) - t['vim_mm'] = raw[o + 13] | ((raw[o + 14] & 0x0F) << 8) - fl = raw[o + 17] - t['interlaced'] = bool(fl & 0x80) - t['sync_type'] = SYNC_TYPES[(fl >> 2) & 3] - t['polarity'] = ('V+' if fl & 0x02 else 'V-') + ' ' + ('H+' if fl & 0x01 else 'H-') - t['htotal'] = t['hact'] + t['hblank'] - t['vtotal'] = t['vact'] + t['vblank'] - t['refresh'] = round(t['clk_khz'] * 1000.0 / (t['htotal'] * t['vtotal']), 1) - return t - - -def _ascii(raw, o, max_len): - out = [] - for i in range(max_len): - c = raw[o + i] - if c in (0x0A, 0x00): - break - if 32 <= c < 127: - out.append(chr(c)) - return ''.join(out) - - -def parse_edid(raw): - """解析 EDID(128 或 256 字节), 返回汇总 dict""" - e = {'raw': raw, 'dtds': [], 'est': [], 'std': [], 'cea_video': [], 'cea_blocks': []} - n = len(raw) - - e['cksum_base'] = (sum(raw[0:128]) & 0xFF) == 0 - - # 厂商 ID: 3 个 5bit 压缩大写字母 - b0, b1 = raw[8], raw[9] - e['manufacturer'] = ''.join([ - chr(((b0 & 0x7C) >> 2) + 64), - chr((((b0 & 0x03) << 3) | ((b1 & 0xE0) >> 5)) + 64), - chr((b1 & 0x1F) + 64)]) - e['product'] = struct.unpack_from('> 4) & 0x7] - e['input'] = '数字 / ' + INTERFACES[raw[20] & 0x7] + \ - (f' / {bpc}bpc' if bpc else ' / 色深未定义') - else: - e['input'] = '模拟 (VGA 输入)' - - e['wcm'], e['hcm'] = raw[21], raw[22] - e['gamma'] = 0 if raw[23] == 0xFF else (raw[23] + 100) / 100.0 - - feats = [] - if raw[24] & 0x80: feats.append('DPMS 待机') - if raw[24] & 0x40: feats.append('DPMS 挂起') - if raw[24] & 0x20: feats.append('DPMS 关闭') - if raw[24] & 0x04: feats.append('sRGB 特性') - if raw[24] & 0x02: feats.append('首选时序=原生') - e['features'] = ', '.join(feats) - - # 已建立时序位域 - for i in range(24): - bit = 7 - (i % 8) - if ((raw[35 + i // 8] >> bit) & 1) and EST_TABLE[i] != '-': - e['est'].append(EST_TABLE[i]) - - # 标准时序(8 组 2 字节, 0x0101 = 未用) - for i in range(38, 54, 2): - a, b = raw[i], raw[i + 1] - if (a == 1 and b == 1) or (a == 0 and b == 0): - continue - h = (a + 31) * 8 - v = round(h * STD_ASPECT[(a >> 6) & 3][0]) - e['std'].append(f'{h}x{v}@{b + 60}') - - # 基础块 4 个 18 字节描述符区: 前两字节非 0 即 DTD, 否则是监视器描述符 - e['name'] = e['serial_text'] = e['range_text'] = '' - for d in range(54, 126, 18): - if raw[d] == 0 and raw[d + 1] == 0: - tag = raw[d + 2] - if tag == 0xFC: - e['name'] = _ascii(raw, d + 5, 13) - elif tag == 0xFF: - e['serial_text'] = _ascii(raw, d + 5, 13) - elif tag == 0xFD: - e['range_text'] = (f"V {raw[d+5]}~{raw[d+6]}Hz, H {raw[d+7]}~{raw[d+8]}kHz, " - f"像素时钟上限 {raw[d+9] * 10}MHz") - else: - e['dtds'].append(parse_dtd(raw, d)) - - # CEA-861 扩展块 - e['has_ext'] = raw[126] > 0 and n >= 256 - e['has_cea'] = False - e['cea_rev'] = 0 - e['cea_caps'] = '' - e['cea_phys'] = '' - e['cksum_ext'] = False - if e['has_ext']: - e['cksum_ext'] = (sum(raw[128:256]) & 0xFF) == 0 - if raw[128] == 0x02: - e['has_cea'] = True - e['cea_rev'] = raw[129] - caps = [] - if raw[131] & 0x80: caps.append('欠扫描') - if raw[131] & 0x40: caps.append('基本音频') - if raw[131] & 0x20: caps.append('YCbCr4:4:4') - if raw[131] & 0x10: caps.append('YCbCr4:2:2') - e['cea_caps'] = ', '.join(caps) - - dtd_off = raw[130] - p = 132 - while dtd_off > 4 and (p - 128) < dtd_off and p + 1 < 256: - tag = (raw[p] >> 5) & 7 - length = raw[p] & 0x1F - if length == 0: - break - data = raw[p + 1:p + 1 + length] - if tag == 2: # 视频数据块: VIC 列表 - for k in range(length): - vic = data[k] & 0x7F - native = ' (原生)' if data[k] & 0x80 else '' - m = (VIC_TABLE[vic - 1] if 1 <= vic <= len(VIC_TABLE) else None) \ - or f'VIC#{vic}' - e['cea_video'].append(m + native) - elif tag == 1: # 音频数据块: N 个 3 字节 SAD - e['cea_blocks'].append(f'音频数据块 x{length // 3}') - elif tag == 4: - e['cea_blocks'].append('扬声器分配块') - elif tag == 5: - e['cea_blocks'].append('VESA DTC 块') - elif tag == 6: - e['cea_blocks'].append('视频格式块(VFDB)') - elif tag == 3: - # 厂商专用块: 前 3 字节 IEEE OUI(小端) - oui = bytes(data[0:3]) - if oui == b'\x03\x0c\x00': # HDMI 1.x VSDB: OUI 后 4 字节物理地址 - if length >= 7: - e['cea_phys'] = '.'.join(str(b) for b in data[3:7]) - e['cea_blocks'].append('HDMI VSDB') - elif oui == b'\xd8\x5d\xc4': # HDMI 2.0 HF-VSDB - e['cea_blocks'].append('HDMI 2.0 HF-VSDB') - else: - e['cea_blocks'].append(f'厂商块 OUI={oui.hex(" ")}') - elif tag == 7: - ext = data[0] if length >= 1 else -1 - name = EXT_TAG_NAMES.get(ext, f'0x{ext:02X}') - e['cea_blocks'].append(f'扩展块({name})') - else: - e['cea_blocks'].append(f'保留块 tag{tag}') - p += 1 + length - - if dtd_off >= 4: # 扩展块 DTD 区 - q = 128 + dtd_off - while q + 18 <= n: - if raw[q] == 0 and raw[q + 1] == 0: - break - e['dtds'].append(parse_dtd(raw, q)) - q += 18 - return e - - -# ============================================================ -# 文本化 -# ============================================================ -def edid_info_lines(e): - """[(项目, 值), ...] 摘要行""" - lines = [] - inch = 0 - if e['wcm'] > 0 and e['hcm'] > 0: - inch = round(math.hypot(e['wcm'], e['hcm']) / 2.54, 1) - lines.append(('厂商 / 产品码', f"{e['manufacturer']} / 0x{e['product']:04X}")) - lines.append(('显示器名称', e['name'] or '(未声明)')) - if e['serial_text']: - lines.append(('序列号(文本)', e['serial_text'])) - lines.append(('生产周 / 年', f"第 {e['week']} 周 / {e['year']} 年")) - lines.append(('EDID 版本', e['ver'])) - lines.append(('输入类型', e['input'])) - if inch > 0: - lines.append(('屏幕尺寸', f"{e['wcm']}cm x {e['hcm']}cm (约 {inch} 英寸)")) - if e['gamma'] > 0: - lines.append(('Gamma', e['gamma'])) - if e['features']: - lines.append(('特性', e['features'])) - if e['range_text']: - lines.append(('范围限制', e['range_text'])) - if e['has_ext']: - ck = 'OK' if e['cksum_ext'] else '错误!' - lines.append(('扩展块', f"CEA-861 rev.{e['cea_rev']} 校验和 {ck}")) - if e['cea_caps']: - lines.append(('CEA 能力', e['cea_caps'])) - if e['cea_phys']: - lines.append(('HDMI 物理地址', e['cea_phys'])) - else: - lines.append(('扩展块', '无')) - lines.append(('基础块校验和', 'OK (0x00)' if e['cksum_base'] else '错误!')) - - # 每个 DTD 的完整时序参数(硬件调试重点) - for k, t in enumerate(e['dtds']): - tag = 'DTD1(首选)' if k == 0 else f'DTD{k + 1}' - lines.append((f'{tag} 模式', f"{t['hact']}x{t['vact']}@{t['refresh']} {t['clk_khz'] / 1000:.3f} MHz")) - lines.append((f'{tag} 总时序', f"{t['htotal']}x{t['vtotal']}")) - lines.append((f'{tag} 水平', f"有效 {t['hact']} | 前沿 {t['hfront']} | 同步 {t['hsync']} | " - f"后沿 {t['htotal'] - t['hact'] - t['hfront'] - t['hsync']}")) - lines.append((f'{tag} 垂直', f"有效 {t['vact']} | 前沿 {t['vfront']} | 同步 {t['vsync']} | " - f"后沿 {t['vtotal'] - t['vact'] - t['vfront'] - t['vsync']}")) - lines.append((f'{tag} 同步', f"{t['sync_type']} {t['polarity']} " - f"{'(隔行)' if t['interlaced'] else '(逐行)'}")) - if t['him_mm'] > 0: - lines.append((f'{tag} 图像尺寸', f"{t['him_mm']}x{t['vim_mm']} mm")) - return lines - - -def hex_dump(b): - out = [] - for row in range((len(b) + 15) // 16): - off = row * 16 - chunk = b[off:off + 16] - # 8+8 分组, 组间双空格: 同步头/厂商段一眼分开 - hx = ' '.join(f'{v:02X}' for v in chunk[:8]) + ' ' + \ - ' '.join(f'{v:02X}' for v in chunk[8:]) - asc = ''.join(chr(v) if 32 <= v < 127 else '.' for v in chunk) - out.append(f'{off:04X}: {hx:<50} {asc}') - return '\n'.join(out) - - -# ============================================================ -# EDID 逐字节字段解析(调试视图): 基础块 + CTA-861 扩展块 -# 每个字段一行: [偏移] 原始字节 | 含义 | 解码值 | 备注 -# ============================================================ -B_HEAD = '同步头 00 FF FF FF FF FF FF 00' - - -def _range_limits(d): - """FD 频率范围描述符(18 字节常规布局) -> V/H 范围与像素时钟上限""" - try: - return (f"V {d[5]}~{d[6]}Hz, H {d[7]}~{d[8]}kHz, " - f"像素时钟上限 {d[9] * 10}MHz") - except (IndexError, ValueError): - return '' - - -def _bits(b, on, off=None): - return f'{b:02X}: ' + ('; '.join(on) if on else (off or '无置位')) - - -def edid_field_rows(raw): - """把 EDID(128/256 字节)逐字段解成 [(偏移, 原始字节, 含义, 解码值, 备注), ...]""" - rows = [] - n = len(raw) - - def add(off, ln, meaning, val, note=''): - hx = raw[off:off + ln].hex(' ').upper() - if ln > 18: # 超长区域(如零填充)截断, 完整数据看 hex dump - hx = raw[off:off + 18].hex(' ').upper() + f' …(共 {ln} 字节)' - rows.append((f'0x{off:02X}-{off + ln - 1:02X}' if ln > 1 else f'0x{off:02X}', - hx, meaning, str(val), note)) - - add(0, 8, B_HEAD, 'OK' if raw[0:8] == bytes.fromhex('00FFFFFFFFFFFF00') else '不匹配!', - '不许改, 用于同步识别') - # 8-9 厂商 ID: 16 位大端, 3 组 5bit 压缩大写字母(块内唯一大端字段) - mfr = ''.join([chr(((raw[8] & 0x7C) >> 2) + 64), - chr((((raw[8] & 0x03) << 3) | ((raw[9] & 0xE0) >> 5)) + 64), - chr((raw[9] & 0x1F) + 64)]) - add(8, 2, '厂商 ID(大端)', mfr, - f"3×5bit 压缩字母, 原始 0x{(raw[8] << 8) | raw[9]:04X}; 正式产品应使用注册的 PNP ID") - add(10, 2, '产品码(小端)', f"0x{struct.unpack_from('> 4) & 0x7] - val = f'数字 / {itf}' + (f' / {bpc}bpc' if bpc else ' / 色深未定义') - else: - val = '模拟输入' - add(20, 1, '输入类型', val, - 'bit7=1 数字; 低 4 位: 1=HDMI-A 2=HDMI-B 4=DP 5=eDP') - - add(21, 1, '最大画面宽(cm)', raw[21], '0 = 未知(横竖比由 DTD 图像尺寸给出)') - add(22, 1, '最大画面高(cm)', raw[22], '0 = 未知') - add(23, 1, 'Gamma', f"{(raw[23] + 100) / 100:.2f}" if raw[23] else '(未提供)', - '存储值 = gamma×100 − 100(如 0x78 → 2.20); 0xFF = 不提供') - add(24, 1, '特性位', _bits(raw[24], [ - *(['DPMS 待机'] if raw[24] & 0x80 else []), - *(['DPMS 挂起'] if raw[24] & 0x40 else []), - *(['DPMS 关闭'] if raw[24] & 0x20 else []), - *([f"显示色彩 {['RGB 4:4:4', 'RGB+YCrCb 4:4:4', 'RGB+YCrCb 4:2:2', '未定义'][(raw[24] >> 3) & 3]}"] - if raw[24] & 0x80 else []), - *(['sRGB 默认'] if raw[24] & 0x04 else []), - *(['首选时序=原生分辨率'] if raw[24] & 0x02 else []), - *(['连续频率/GTF'] if raw[24] & 0x01 else []), - ]), 'bit2 sRGB / bit1 首选时序有效 / bit0 连续频率') - add(25, 10, '色度坐标(CIE xy)', '默认 sRGB 模板' if raw[25:35] == bytes.fromhex( - '78EE95A3554D9EA6544C') else '(打包位段)', '红绿蓝白四点 xy 各 10bit 打包, 通常照抄模板') - est = [] - est_bits = 0 - for i in range(24): - bit = 7 - (i % 8) - if (raw[35 + i // 8] >> bit) & 1: - est_bits |= 1 << (23 - i) - if EST_TABLE[i] != '-': - est.append(EST_TABLE[i]) - if est: - add(35, 3, 'Established Timing 位图', ', '.join(est), - 'VESA 老 VGA 时序; 数字屏常写 FF FF 00=全支持') - else: - add(35, 3, 'Established Timing 位图', - '无有效声明' + ('(仅保留位置位)' if est_bits else ''), - 'VESA 老 VGA 时序; 数字屏常写 FF FF 00=全支持') - stds = [] - for i in range(38, 54, 2): - a, b = raw[i], raw[i + 1] - if (a == 1 and b == 1) or (a == 0 and b == 0): - continue - h = (a + 31) * 8 - ar = STD_ASPECT[(a >> 6) & 3][1] - stds.append(f"{h}x{round(h * STD_ASPECT[(a >> 6) & 3][0])}@{b + 60} {ar}") - add(38, 16, '标准时序 ×8', '; '.join(stds) if stds else '8 槽全部未用(01 01)', - '2 字节/槽: 6bit 宽 + 2bit 比例 + 刷新率−60; 未用槽填 01 01') - - # 0x36-0x7D: 4 × 18 字节描述符 - for k, d in enumerate(range(54, 126, 18)): - grp = raw[d:d + 18] - if grp[0] or grp[1]: - t = parse_dtd(raw, d) - note = (f"{'(隔行)' if t['interlaced'] else '(逐行)'} " - f"{t['sync_type']} {'/'.join(t['polarity'].split())}; " - f"消隐 H{t['hblank']}/V{t['vblank']}, " - f"前/同/后 H{t['hfront']}/{t['hsync']}/{t['htotal'] - t['hact'] - t['hfront'] - t['hsync']} " - f"V{t['vfront']}/{t['vsync']}/{t['vtotal'] - t['vact'] - t['vfront'] - t['vsync']}") - add(d, 18, f'描述符{k + 1}: DTD(详细时序)', - f"{t['hact']}×{t['vact']}@{t['refresh']}Hz " - f"总 {t['htotal']}×{t['vtotal']} {t['clk_khz'] / 1000:.3f}MHz" - + (' [首选时序]' if k == 0 else ''), - note + ('; 图像尺寸 %dx%d mm' % (t['him_mm'], t['vim_mm']) if t['him_mm'] else '')) - else: - tag = grp[3] - txt = _ascii(raw, d + 5, 13) - if tag == 0xFF: - add(d, 18, f'描述符{k + 1}: 序列号串(0xFF)', repr(txt), 'ASCII, 上限 13 字节') - elif tag == 0xFC: - add(d, 18, f'描述符{k + 1}: 显示器名(0xFC)', repr(txt), 'ASCII, 上限 13 字节') - elif tag == 0xFD: - add(d, 18, f'描述符{k + 1}: 范围限制(0xFD)', _range_limits(grp), '频率范围描述符') - elif tag == 0xFE: - add(d, 18, f'描述符{k + 1}: 无界文本(0xFE)', repr(txt), '') - elif tag == 0x10: - add(d, 18, f'描述符{k + 1}: 数据串(0x10)', grp[5:18].hex(' ').upper(), '二进制用途') - elif tag == 0 and not any(grp): - add(d, 18, f'描述符{k + 1}: 空', '(全 00)', '') - else: - add(d, 18, f'描述符{k + 1}: 未知类型 0x{tag:02X}', grp[5:18].hex(' ').upper(), '') - - add(126, 1, '扩展块数量', raw[126], '必须与实际扩展块数一致') - cksum = (256 - sum(raw[0:127])) & 0xFF - add(127, 1, '基础块校验和', f"0x{raw[127]:02X} → " + ('正确' if (sum(raw[0:128]) & 0xFF) == 0 else '错误!'), - f"整块 128 字节和 ≡ 0 (mod 256); 期望 0x{cksum:02X} = 256 − 前 127 字节和") - - # ---- CTA-861 扩展块 ---- - n_ext = raw[126] if n > 126 else 0 - for bi in range(n_ext): - base = 128 + bi * 128 - if base + 128 > n: - add(base, 0, f'扩展块{bi + 1}', '(数据不完整)', '') - break - blk = raw[base:base + 128] - add(base, 1, f'扩展块{bi + 1} @0x{base:02X} Tag', - 'CTA-861' if blk[0] == 0x02 else f'0x{blk[0]:02X}(非 CTA)', - '固定 0x02 = CTA 扩展; HDMI 必须有') - if blk[0] != 0x02: - add(base + 1, 127, '扩展块数据', blk[1:128].hex(' ').upper(), '未知扩展类型, 跳过') - continue - add(base + 1, 1, '版本', f"rev {blk[1]}", '常用 03') - add(base + 2, 1, 'DTD 起始偏移', f"0x{blk[2]:02X} (= 块内 {blk[2]} 字节处)", '数据块链结束、DTD 区开始的偏移') - add(base + 3, 1, '标志位', _bits(blk[3], [ - *(['bit7 支持 YCbCr 4:2:0'] if blk[3] & 0x80 else []), - *(['bit6 支持基础音频'] if blk[3] & 0x40 else []), - *(['bit5 支持 YCbCr 4:2:2'] if blk[3] & 0x20 else []), - *(['bit4 支持 YCbCr 4:4:4'] if blk[3] & 0x10 else []), - *(['bit0 支持欠扫描'] if blk[3] & 0x01 else []), - ]), 'bit6=基础音频, bit5=4:2:2, bit7=4:2:0, bit0=欠扫描') - - # 数据块链: 每块 1 字节头(高 3 位 tag + 低 5 位长度) + payload - TAG_NAMES = {1: '音频', 2: '视频 VIC', 3: '厂商专用 VSDB', 4: '扬声器分配', - 5: 'VESA DTC', 6: '视频格式 VFDB', 7: '扩展 tag'} - p, first = 4, True - while base + p < base + 128 and p < blk[2] and p + 1 < 128: - tag, length = (blk[p] >> 5) & 7, blk[p] & 0x1F - payload = blk[p + 1:p + 1 + length] - if first and blk[p] == 0: - add(base + p, 1, '数据块链', '(无数据块, 直接进 DTD 区)', '4 至 DTD 区前为数据块链') - break - first = False - if tag == 2: - vics = [] - for v in payload: - vi = v & 0x7F - name = (VIC_TABLE[vi - 1] if 1 <= vi <= len(VIC_TABLE) else None) or f'VIC#{vi}' - vics.append(name + ('(原生)' if v & 0x80 else '')) - add(base + p, 1 + length, '数据块: 视频', ' '.join(vics) or '(空)', f"tag 2, {length} 字节") - elif tag == 1: - fmts = [] - codes = ['保留', 'LPCM', 'AAC', 'AC-3', 'MPEG1', 'MP3', 'MPEG2', 'AAC LC', - 'DTS', 'ATRAC', 'DSD', 'E-AC-3', 'DTS-HD', 'MLP', 'DST', 'WMAPro'] - for si in range(length // 3): - sad = payload[si * 3:(si + 1) * 3] - ch = (sad[0] & 7) + 1 - rates = '/'.join(str(r) for r, b in ((32, 1), (44, 2), (48, 4)) if sad[2] & b) or '?' - mults = ''.join(f'×{m}' for m, b in ((2, 8), (3, 16), (4, 32)) if sad[2] & b) - if (sad[0] >> 3) & 0xF == 1: # LPCM: 位宽在字节1低3位(位图) - bits = '/'.join(str(v) for v, b in ((16, 1), (20, 2), (24, 4)) if sad[1] & b) or '?' - fmts.append(f'LPCM {ch}ch {bits}bit {rates}kHz{mults}') - else: # 压缩格式: 字节1高4位=最高采样率档 - fmts.append(f"{codes[(sad[0] >> 3) & 0xF]} {ch}ch {rates}kHz{mults}") - add(base + p, 1 + length, '数据块: 音频', '; '.join(fmts), f"tag 1, {length // 3} × 3 字节 SAD") - elif tag == 3: - oui = bytes(payload[0:3]) - names = {b'\x03\x0c\x00': 'HDMI 1.x VSDB', b'\xd8\x5d\xc4': 'HDMI 2.0 HF-VSDB'} - val = names.get(oui, f'OUI={oui.hex(" ").upper()}') - if oui == b'\x03\x0c\x00' and length >= 7: - val += f", 物理地址 {'.'.join(str(b) for b in payload[3:7])}" - add(base + p, 1 + length, '数据块: 厂商专用', val, - f"tag 3, 头 3 字节 IEEE OUI(小端){'; HDMI 2.0 需 HF-VSDB' if oui == b'\x03\x0c\x00' else ''}") - else: - name = TAG_NAMES.get(tag, f'tag{tag}') - add(base + p, 1 + length, f'数据块: {name}', - payload.hex(' ').upper() if length else '(空)', f"{length} 字节 payload") - if length == 0: - break - p += 1 + length - - # DTD 区 - if blk[2] >= 4: - q, cnt = blk[2], 0 - while q + 18 <= 128 and any(blk[q:q + 2]): - t = parse_dtd(blk, q) - add(base + q, 18, f'扩展块 DTD{cnt + 1}', - f"{t['hact']}×{t['vact']}@{t['refresh']}Hz 总 {t['htotal']}×{t['vtotal']} " - f"{t['clk_khz'] / 1000:.3f}MHz", - "前/同/后 H%d/%d/%d V%d/%d/%d %s" % ( - t['hfront'], t['hsync'], t['htotal'] - t['hact'] - t['hfront'] - t['hsync'], - t['vfront'], t['vsync'], t['vtotal'] - t['vact'] - t['vfront'] - t['vsync'], - '(隔行)' if t['interlaced'] else '(逐行)')) - cnt += 1 - q += 18 - if not cnt: - add(base + blk[2], 0, '扩展块 DTD 区', '(空)', '未用空间填 0') - rem = 128 - max(blk[2], 4) - 18 * cnt if blk[2] >= 4 else 0 - if blk[2] >= 4 and rem > 0 and not any(blk[128 - rem:128 - 1]): - add(base + 128 - rem, rem - 1, '未用空间', '0 填充', '') - add(base + 127, 1, f'扩展块{bi + 1} 校验和', - f"0x{blk[127]:02X} → " + ('正确' if (sum(blk) & 0xFF) == 0 else '错误!'), - f"期望 0x{(256 - sum(blk[0:127])) & 0xFF:02X} (同基础块规则)") - return rows - - -def edid_field_text(raw): - """逐字段解析 -> 多行文本(命令行 / 文本框共用)""" - w = (7, 26, 20) - out = ['%-*s %-*s %-*s %s' % (w[0], '偏移', w[1], '含义', w[2], '解码值', '备注 / 原始字节')] - for off, hx, meaning, val, note in edid_field_rows(raw): - line = '%-*s %-*s %-*s %s' % (w[0], off, w[1], meaning, w[2], val, note) - out.append(line.rstrip()) - if hx: - out.append('%-*s %-*s %s %s' % (w[0], '', w[1], '', '原始', hx)) - return '\n'.join(out) - - -# ============================================================ -# 测试图案窗口(tkinter) — 测试卡区域可选 640x360/800x600/全屏 -# ============================================================ -PAT_W, PAT_H = 640, 360 -AREA_OPTIONS = ('640x360', '800x600', 'full') -DEFAULT_AREA = AREA_OPTIONS[0] -PATTERN_NAMES = {'Red': '纯红', 'Green': '纯绿', 'Blue': '纯蓝', - 'Checker': '黑白棋盘格', 'Grid': '网格线'} -# 纯色测试值(位交换测试用, 与 assets/solid_*.png 对应): -# 每通道 55/AA 互为镜像对(检测对), FF 全高基准 -SOLID_ROWS = ( - ('R', (('55', '#550000'), ('AA', '#AA0000'), ('FF', '#FF0000'))), - ('G', (('55', '#005500'), ('AA', '#00AA00'), ('FF', '#00FF00'))), - ('B', (('55', '#000055'), ('AA', '#0000AA'), ('FF', '#0000FF'))), -) - - -def _area_box(spec, w, h): - """区域选项 -> (x0, y0, aw, ah): 'full' 铺满整个屏幕, 'WxH' 黑底居中""" - if spec == 'full': - return 0, 0, w, h - try: - aw, ah = (int(v) for v in spec.lower().split('x', 1)) - except Exception: - aw, ah = PAT_W, PAT_H - return (w - aw) // 2, (h - ah) // 2, aw, ah - - -def _open_fullscreen(parent, rect, auto_close=0): - """创建覆盖 rect(x,y,w,h) 的无边框全屏黑底窗口, 返回 (top, cv) - - 只支持 ESC 退出: overrideredirect 窗口必须 focus_force 强取键盘焦点, - 否则收不到按键(Windows 上该组合有效)。 - """ - import tkinter as tk - rx, ry, rw, rh = rect - top = tk.Toplevel(parent) - top.overrideredirect(True) - top.geometry(f'{rw}x{rh}+{rx}+{ry}') - top.attributes('-topmost', True) - top.configure(bg='black') - - cv = tk.Canvas(top, bg='black', highlightthickness=0, bd=0) - cv.pack(fill='both', expand=True) - - def close(_evt=None): - top.destroy() - - top.bind('', close) - if auto_close > 0: - top.after(auto_close * 1000, close) - top.focus_force() - return top, cv - - -def show_pattern(parent, rect, kind, auto_close=0, area=DEFAULT_AREA): - """在 rect 指定的屏幕上全屏出居中测试卡(无任何 OSD 修饰), 区域由 area 指定""" - top, cv = _open_fullscreen(parent, rect, auto_close) - - def draw(_evt=None): - cv.delete('all') - w, h = max(cv.winfo_width(), rect[2]), max(cv.winfo_height(), rect[3]) - x0, y0, aw, ah = _area_box(area, w, h) - - if kind.startswith('#'): # 纯色测试值: 直接填充指定颜色 - cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill=kind, outline='') - elif kind == 'Red': - cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill='#FF0000', outline='') - elif kind == 'Green': - cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill='#00FF00', outline='') - elif kind == 'Blue': - cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill='#0000FF', outline='') - elif kind == 'Checker': - # 40px 黑白格, 格数随区域尺寸自适应(640x360 = 16x9 格) - for gy in range((ah + 39) // 40): - for gx in range((aw + 39) // 40): - if (gx + gy) % 2 == 0: - cv.create_rectangle(x0 + gx * 40, y0 + gy * 40, - x0 + (gx + 1) * 40, y0 + (gy + 1) * 40, - fill='#FFFFFF', outline='') - elif kind == 'Grid': - # 20px 白色网格线, 黑底, 无其它修饰 - for k in range(aw // 20 + 1): - cv.create_line(x0 + k * 20, y0, x0 + k * 20, y0 + ah, fill='#FFFFFF') - for k in range(ah // 20 + 1): - cv.create_line(x0, y0 + k * 20, x0 + aw, y0 + k * 20, fill='#FFFFFF') - - top.bind('', draw) - top.after(50, draw) - top.wait_window(top) - - -def show_pattern_image(parent, rect, path, auto_close=0, area=DEFAULT_AREA): - """全屏黑底, 图片 1:1 居中于 area 指定的测试卡区域: - 小于区域 -> 真实像素居中, 四周黑边; - 大于区域 -> 等效从中心截取(区域外用黑矩形遮挡)""" - top, cv = _open_fullscreen(parent, rect, auto_close) - - def draw(_evt=None): - cv.delete('all') - w, h = max(cv.winfo_width(), rect[2]), max(cv.winfo_height(), rect[3]) - x0, y0, aw, ah = _area_box(area, w, h) - photo = load_image_raw(path) - top._photo = photo # 挂在窗口上防止被垃圾回收 - iw, ih = photo.width(), photo.height() - cv.create_image(x0 + (aw - iw) // 2, y0 + (ah - ih) // 2, - image=photo, anchor='nw') - # 四块黑矩形盖住测试卡区域之外 -> 大图等效中心裁剪 - cv.create_rectangle(-2, -2, w + 2, y0, fill='black', outline='') - cv.create_rectangle(-2, y0 + ah, w + 2, h + 2, fill='black', outline='') - cv.create_rectangle(-2, y0, x0, y0 + ah, fill='black', outline='') - cv.create_rectangle(x0 + aw, y0, w + 2, y0 + ah, fill='black', outline='') - - top.bind('', draw) - top.after(50, draw) - top.wait_window(top) - - -# ============================================================ -# 主 GUI -# ============================================================ def run_gui(auto_close=0): - import tkinter as tk - from tkinter import ttk - - # 让任务栏图标独立显示(不归并到 Python/通用组), 须在创建窗口前调用 - try: - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID('hulk.HDMI-Tool') - except Exception: - pass - - class App(tk.Tk): - def __init__(self): - super().__init__() - self.title('HDMI-Tool — EDID 查看器 + 测试图案 + 光机控制 (LT8619C 转接板调试)') - self.geometry('960x1100') - ic = get_icon_path() - if ic: - try: - self.iconbitmap(ic) # 窗口标题栏图标 - except Exception: - pass - self.displays = [] - self.slots, self.area = load_slots() - self._modes = {} # 'WxH@Hz' -> DEVMODEW(当前所选屏) - 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_connected = False - self._build() - - def _build(self): - # --- 顶部: 显示器选择 --- - top = ttk.Frame(self, padding=(8, 6)) - top.pack(fill='x') - ttk.Label(top, text='显示器:').pack(side='left') - self.cbo = ttk.Combobox(top, state='readonly', width=62) - self.cbo.pack(side='left', padx=6) - self.cbo.bind('<>', self._on_select) - ttk.Button(top, text='刷新', command=self.load_displays, width=8).pack(side='left') - self.lbl_cur = ttk.Label(top, text='') - self.lbl_cur.pack(side='left', padx=12) - - # --- 顶部第二行: 分辨率切换 --- - top2 = ttk.Frame(self, padding=(8, 2)) - top2.pack(fill='x') - ttk.Label(top2, text='分辨率:').pack(side='left') - self.cbo_mode = ttk.Combobox(top2, state='readonly', width=16) - self.cbo_mode.pack(side='left', padx=6) - ttk.Button(top2, text='应用', width=8, command=self._apply_mode).pack(side='left') - ttk.Button(top2, text='恢复', width=8, command=self._restore_mode).pack(side='left', padx=6) - ttk.Label(top2, text='列表为显卡按该屏 EDID 提供的模式; 切换临时生效, 重启还原', - foreground='#666666').pack(side='left', padx=10) - - # --- 中部: 模式列表 + EDID 信息 --- - mid = ttk.Frame(self, padding=(8, 0)) - mid.pack(fill='both', expand=True) - - left = ttk.LabelFrame(mid, text='支持的模式 (EDID 解算)', padding=4) - left.pack(side='left', fill='both', expand=True) - self.tv_modes = ttk.Treeview(left, columns=('mode', 'hz', 'clk', 'src'), - show='headings', height=12) - for col, txt, w, anchor in (('mode', '模式', 110, 'w'), ('hz', '刷新率', 65, 'e'), - ('clk', '像素时钟', 80, 'e'), ('src', '来源', 90, 'w')): - self.tv_modes.heading(col, text=txt) - self.tv_modes.column(col, width=w, anchor=anchor) - sb1 = ttk.Scrollbar(left, orient='vertical', command=self.tv_modes.yview) - self.tv_modes.configure(yscrollcommand=sb1.set) - self.tv_modes.pack(side='left', fill='both', expand=True) - sb1.pack(side='right', fill='y') - - right = ttk.LabelFrame(mid, text='EDID 信息', padding=4) - right.pack(side='left', fill='both', expand=True, padx=(6, 0)) - self.tv_info = ttk.Treeview(right, columns=('k', 'v'), show='headings', height=12) - self.tv_info.heading('k', text='项目') - self.tv_info.heading('v', text='值') - self.tv_info.column('k', width=150, anchor='w') - self.tv_info.column('v', width=420, anchor='w') - sb2 = ttk.Scrollbar(right, orient='vertical', command=self.tv_info.yview) - self.tv_info.configure(yscrollcommand=sb2.set) - self.tv_info.pack(side='left', fill='both', expand=True) - sb2.pack(side='right', fill='y') - - # --- 测试图案按钮组 --- - grp = ttk.LabelFrame(self, text='测试图案 — 全屏投至所选显示器, 黑底 1:1 居中, 按 ESC 退出', - padding=(10, 6)) - grp.pack(fill='x', padx=8, pady=(6, 0)) - row1 = ttk.Frame(grp) - row1.pack(fill='x') - for key in ('Red', 'Green', 'Blue', 'Checker', 'Grid'): - ttk.Button(row1, text=PATTERN_NAMES[key], width=12, - command=lambda k=key: self._show_pattern(k)).pack(side='left', padx=5) - # 纯色测试值: 保持"通道标签 + 55/AA/FF 按钮"形式, 三组横向一行排布 - rowf = ttk.Frame(grp) - rowf.pack(fill='x', pady=(3, 0)) - for ch, items in SOLID_ROWS: - ttk.Label(rowf, text=f'{ch}: 0x', width=4).pack(side='left') - for label, color in items: - ttk.Button(rowf, text=label, width=5, - command=lambda c=color: self._show_solid(c) - ).pack(side='left', padx=3) - ttk.Label(row1, text='测试卡区域:').pack(side='left', padx=(16, 2)) - self.area_var = tk.StringVar(value=self.area) - cbo_area = ttk.Combobox(row1, textvariable=self.area_var, width=8, - state='readonly', values=list(AREA_OPTIONS)) - cbo_area.pack(side='left') - cbo_area.bind('<>', lambda _e: self._save_area()) - row2 = ttk.Frame(grp) - row2.pack(fill='x', pady=(4, 0)) - self.slot_buttons = [] - for i in range(SLOT_COUNT): - cell = ttk.Frame(row2) - cell.pack(side='left', padx=(5, 0)) - btn = ttk.Button(cell, text=self.slots[i]['name'], width=12, - command=lambda i=i: self._show_custom(i)) - btn.pack(side='left') - ttk.Button(cell, text='⚙', width=3, - command=lambda i=i: self._configure_slot(i)).pack(side='left', padx=(4, 0)) - self.slot_buttons.append(btn) - ttk.Label(row2, text='自定义: 点击投图, ⚙ 配置名称与图片(图片保存在脚本目录)', - foreground='#666666').pack(side='left', padx=10) - - # --- 光机控制: RS485 -> 板载 GD32E230 网关 -> DLPC3421 --- - grp2 = ttk.LabelFrame( - self, text='光机控制 — RS485 → 板载 GD32 网关 → DLPC3421 (115200 8N1)', - padding=(10, 6)) - grp2.pack(fill='x', padx=8, pady=(6, 0)) - if not dlpc_rs485.HAS_SERIAL: - ttk.Label(grp2, foreground='#B05000', - text='未安装 pyserial, 光机控制不可用 — ' - '执行 py -m pip install pyserial 后重启本工具').pack(anchor='w') - else: - r1 = ttk.Frame(grp2) - r1.pack(fill='x') - ttk.Label(r1, text='串口:').pack(side='left') - # 下拉显示 "COMx + 设备描述"(CH344 四路按 serial-A/B/C/D 等描述区分) - self._dlpc_ports = dlpc_rs485.DLPC485.list_ports_info() - self.cbo_dlpc = ttk.Combobox(r1, width=22, state='readonly', - values=[lb for _d, lb in self._dlpc_ports]) - if SERIAL_CFG['port']: - self._dlpc_pick(SERIAL_CFG['port']) - self.cbo_dlpc.pack(side='left', padx=(4, 2)) - ttk.Button(r1, text='刷新', width=6, - command=self._dlpc_refresh).pack(side='left') - ttk.Label(r1, text='波特率:').pack(side='left', padx=(10, 0)) - self.var_baud = tk.StringVar(value=str(SERIAL_CFG['baud'])) - ttk.Combobox(r1, width=8, textvariable=self.var_baud, - values=('115200', '57600', '38400', '9600') - ).pack(side='left', padx=4) - self.btn_dlpc = ttk.Button(r1, text='连接', width=8, - command=self._dlpc_toggle) - self.btn_dlpc.pack(side='left', padx=(10, 0)) - self.lbl_dlpc_st = ttk.Label(r1, text='未连接', foreground='#666666') - self.lbl_dlpc_st.pack(side='left', padx=10) - - r2 = ttk.Frame(grp2) - r2.pack(fill='x', pady=(4, 0)) - for name, cmd in (('开光机', 'M730S0'), ('关光机', 'M730S1'), - ('查电流', 'M732'), ('查版本', 'M999'), - ('软件重启', 'M888')): - ttk.Button(r2, text=name, width=9, - command=lambda c=cmd: self._dlpc_send(c) - ).pack(side='left', padx=3) - ttk.Label(r2, text='光强(54-800):').pack(side='left', padx=(12, 2)) - self.var_light = tk.StringVar(value='100') - ttk.Entry(r2, textvariable=self.var_light, width=6).pack(side='left') - ttk.Button(r2, text='设光强', width=7, - command=self._dlpc_set_light).pack(side='left', padx=4) - - r3 = ttk.Frame(grp2) - r3.pack(fill='x', pady=(4, 0)) - ttk.Label(r3, text='命令:').pack(side='left') - self._dlpc_presets = [(n, c) for n, c, _ in dlpc_rs485.PRESETS] - self.cbo_dlpc_cmd = ttk.Combobox( - r3, state='readonly', width=26, - values=[f'{n} {c}' for n, c in self._dlpc_presets]) - self.cbo_dlpc_cmd.current(0) - self.cbo_dlpc_cmd.pack(side='left', padx=(4, 2)) - ttk.Button(r3, text='发送', width=7, - command=self._dlpc_send_preset).pack(side='left') - ttk.Label(r3, text='自定义:').pack(side='left', padx=(12, 2)) - self.var_custom_cmd = tk.StringVar() - ent_cmd = ttk.Entry(r3, textvariable=self.var_custom_cmd, width=14) - ent_cmd.pack(side='left') - ent_cmd.bind('', lambda _e: self._dlpc_send_custom()) - ttk.Button(r3, text='发送', width=7, - command=self._dlpc_send_custom).pack(side='left', padx=4) - ttk.Label(r3, text='如 M730S0T2000(定时 2 秒投光)', - foreground='#666666').pack(side='left', padx=6) - - r4 = ttk.Frame(grp2) - r4.pack(fill='x', pady=(4, 0)) - self.txt_dlpc = tk.Text(r4, height=4, font=('Consolas', 9), - state='disabled', wrap='none', - background='#101010', foreground='#C8C8C8') - sb4 = ttk.Scrollbar(r4, orient='vertical', command=self.txt_dlpc.yview) - self.txt_dlpc.configure(yscrollcommand=sb4.set) - self.txt_dlpc.pack(side='left', fill='both', expand=True) - sb4.pack(side='right', fill='y') - self._dlpc_log('帧格式: D5 01 LEN <命令> CRC / B5 TYPE LEN CRC, ' - '累加和校验; 全部命令见 README"光机控制"') - self.after(80, self._poll_dlpc) - - # --- 底部: EDID hex dump + 逐字节解析按钮 --- - bot = ttk.LabelFrame(self, text='EDID 原始数据', padding=4) - bot.pack(fill='both', expand=False, padx=8, pady=(6, 8)) - side = ttk.Frame(bot) - side.pack(side='right', fill='y') - ttk.Button(side, text='逐字节解析', width=10, command=self._show_edid_fields - ).pack(side='top', pady=(2, 4)) - self.txt_hex = tk.Text(bot, height=8, font=('Consolas', 9), - state='disabled', wrap='none', background='#101010', - foreground='#C8C8C8') - sb3 = ttk.Scrollbar(bot, orient='vertical', command=self.txt_hex.yview) - self.txt_hex.configure(yscrollcommand=sb3.set) - self.txt_hex.pack(side='left', fill='both', expand=True) - sb3.pack(side='right', fill='y') - - def load_displays(self): - keep = self.cbo.current() - self.displays = get_displays() - self.cbo['values'] = [f"{d['device']} {d['adapter']} [{d['mon_name']}]" for d in self.displays] - if self.displays: - self.cbo.current(max(0, min(keep, len(self.displays) - 1))) - self._on_select() - else: - self.lbl_cur.config(text='未找到活动显示器') - - def _on_select(self, _evt=None): - idx = self.cbo.current() - if idx < 0 or idx >= len(self.displays): - return - d = self.displays[idx] - self.lbl_cur.config(text=f"当前输出: {d['cur_mode']}") - # 分辨率下拉: 该屏可用模式, 默认选中当前模式 - keys, modes = enum_modes(d['device']) - self._modes = modes - self.cbo_mode['values'] = keys - if d['cur_mode'] in modes: - self.cbo_mode.set(d['cur_mode']) - elif keys: - self.cbo_mode.set(keys[0]) - for tv in (self.tv_modes, self.tv_info): - tv.delete(*tv.get_children()) - if not d['edid']: - self.tv_info.insert('', 'end', values=('EDID', '读取失败(注册表无 EDID 数据)')) - return - e = parse_edid(d['edid']) - for k, v in edid_info_lines(e): - self.tv_info.insert('', 'end', values=(k, v)) - for k, t in enumerate(e['dtds']): - src = 'DTD 首选' if k == 0 else 'DTD' - self.tv_modes.insert('', 'end', values=( - f"{t['hact']}x{t['vact']}", f"{t['refresh']}Hz", - f"{t['clk_khz'] / 1000:.2f}MHz", src)) - for m in e['cea_video']: - wh, _, hz = m.partition('@') - self.tv_modes.insert('', 'end', values=(wh, hz.split(' ')[0] + 'Hz', '', 'CEA-VIC')) - for m in e['est'] + e['std']: - wh, _, hz = m.partition('@') - src = '已建立时序' if m in e['est'] else '标准时序' - self.tv_modes.insert('', 'end', values=(wh, hz + 'Hz', '', src)) - self.txt_hex.configure(state='normal') - self.txt_hex.delete('1.0', 'end') - self.txt_hex.insert('1.0', hex_dump(d['edid'])) - self.txt_hex.configure(state='disabled') - - def _save_area(self): - self.area = self.area_var.get() - save_slots(self.slots, self.area) - - # ---- 光机控制(RS485) ---- - def _dlpc_on_event(self, line): - self._dlpc_log_q.put(('event', line)) - - def _dlpc_on_status(self, msg): - self._dlpc_log_q.put(('status', msg)) - - def _poll_dlpc(self): - """串口线程 -> GUI 主线程: 80ms 轮询日志队列并同步连接状态""" - try: - while True: - kind, text = self._dlpc_log_q.get_nowait() - if kind == 'status': - if text.startswith('已连接'): - color = '#2E7D32' - elif ('失败' in text or '丢失' in text or '未' in text - or '非法' in text or '丢弃' in text): - color = '#B05000' - else: - color = '#666666' - self.lbl_dlpc_st.config(text=text, foreground=color) - self._dlpc_log(text) - except queue.Empty: - pass - connected = self.dlpc.is_connected() - if connected != self._dlpc_connected: - self._dlpc_connected = connected - self.btn_dlpc.config(text='断开' if connected else '连接') - self._dlpc_after_id = self.after(80, self._poll_dlpc) - - def _dlpc_log(self, line): - self.txt_dlpc.configure(state='normal') - self.txt_dlpc.insert('end', time.strftime('%H:%M:%S ') + line + '\n') - if int(self.txt_dlpc.index('end-1c').split('.')[0]) > 300: - self.txt_dlpc.delete('1.0', '2.0') # 只保留最近约 300 行 - self.txt_dlpc.see('end') - self.txt_dlpc.configure(state='disabled') - - def _dlpc_refresh(self): - self._dlpc_ports = dlpc_rs485.DLPC485.list_ports_info() - self.cbo_dlpc['values'] = [lb for _d, lb in self._dlpc_ports] - devices = [d for d, _l in self._dlpc_ports] - if SERIAL_CFG['port'] in devices: - self._dlpc_pick(SERIAL_CFG['port']) - elif devices: - self._dlpc_pick(devices[0]) - - def _dlpc_pick(self, device): - """按 COM 口名选中下拉项(下拉显示的是带描述的标签)""" - for i, (d, _label) in enumerate(self._dlpc_ports): - if d == device: - self.cbo_dlpc.current(i) - return - - def _dlpc_selected_port(self): - """当前下拉选中的 COM 口名""" - i = self.cbo_dlpc.current() - if 0 <= i < len(self._dlpc_ports): - return self._dlpc_ports[i][0] - return self.cbo_dlpc.get().strip() - - def _dlpc_toggle(self): - if self.dlpc.is_connected(): - self.dlpc.close() - return - port = self._dlpc_selected_port() - if not port: - self.lbl_dlpc_st.config(text='未选择串口', foreground='#B05000') - return - try: - baud = int(self.var_baud.get()) - except ValueError: - self.lbl_dlpc_st.config(text='波特率非法', foreground='#B05000') - return - SERIAL_CFG['port'], SERIAL_CFG['baud'] = port, baud - save_slots(self.slots, self.area_var.get()) # 记住本次串口 - self.dlpc.open(port, baud) - - def _dlpc_send(self, cmd): - if not self.dlpc.is_connected(): - self.lbl_dlpc_st.config(text='未连接串口', foreground='#B05000') - return - self.dlpc.send(cmd) - - def _dlpc_send_preset(self): - i = self.cbo_dlpc_cmd.current() - if i >= 0: - self._dlpc_send(self._dlpc_presets[i][1]) - - def _dlpc_send_custom(self): - cmd = self.var_custom_cmd.get().strip() - if cmd: - self._dlpc_send(cmd) - - def _dlpc_set_light(self): - from tkinter import messagebox - try: - v = int(self.var_light.get()) - except ValueError: - messagebox.showinfo('光强非法', '光强需为 54-800 的整数') - return - if not 54 <= v <= 800: - messagebox.showinfo('光强越界', '光强范围 54-800(越界固件会回 err:OL)') - return - self._dlpc_send(f'M731S{v}') - - def destroy(self): - try: # 取消待触发的轮询回调, 避免销毁后 Tcl 报错 - self.after_cancel(self._dlpc_after_id) - except (KeyError, AttributeError, Exception): - pass - try: # 两种退出路径(关窗/--seconds 自动关)都收掉串口线程 - self.dlpc.close() - except Exception: - pass - super().destroy() - - def _show_edid_fields(self): - """逐字节字段解析弹窗(当前所选显示器), 自动换行文本视图""" - from tkinter import messagebox - idx = self.cbo.current() - if idx < 0 or idx >= len(self.displays): - return - raw = self.displays[idx]['edid'] - if not raw: - messagebox.showinfo('无 EDID', '该显示器注册表中没有 EDID 数据, 无法逐字节解析') - return - dlg = tk.Toplevel(self) - dlg.title(f'EDID 逐字节解析 — {self.displays[idx]["device"]} ' - f'[{self.displays[idx]["mon_name"]}]') - dlg.geometry('1180x720') - txt = tk.Text(dlg, font=('Microsoft YaHei UI', 9), wrap='char', - background='#101010', foreground='#C8C8C8', - padx=10, pady=8) - sb = ttk.Scrollbar(dlg, orient='vertical', command=txt.yview) - txt.configure(yscrollcommand=sb.set) - txt.pack(side='left', fill='both', expand=True) - sb.pack(side='right', fill='y') - # 字段行加亮, 原始字节补充行暗色, 换行内容自动缩进可读 - for off, hx, meaning, val, note in edid_field_rows(raw): - txt.insert('end', f'{off} {meaning} {val}\n', 'field') - if hx: - txt.insert('end', f' 原始: {hx}\n', 'raw') - if note: - txt.insert('end', f' {note}\n', 'note') - txt.tag_configure('field', foreground='#E8E8E8') - txt.tag_configure('raw', foreground='#7F7F7F') - txt.tag_configure('note', foreground='#9FB8CC') - txt.configure(state='disabled') - ttk.Button(dlg, text='关闭', command=dlg.destroy).pack(pady=(0, 6)) - dlg.grab_set() - - def _show_solid(self, color): - idx = self.cbo.current() - if idx < 0 or idx >= len(self.displays): - return - d = self.displays[idx] - if not d['rect']: - return - show_pattern(self, d['rect'], color, area=self.area_var.get()) - - def _apply_mode(self): - from tkinter import messagebox - idx, mi = self.cbo.current(), self.cbo_mode.current() - if idx < 0 or idx >= len(self.displays) or mi < 0: - return - dm = self._modes.get(self.cbo_mode.get()) - if dm is None: - return - dev = self.displays[idx]['device'] - if dev not in self._orig_modes: # 记住首次切改前的原始模式, 供"恢复"用 - cur = DEVMODEW() - cur.dmSize = ctypes.sizeof(cur) - if user32.EnumDisplaySettingsW(dev, ENUM_CURRENT_SETTINGS, ctypes.byref(cur)): - self._orig_modes[dev] = cur - ok, r = apply_mode(dev, dm) - if not ok: - messagebox.showerror('切换失败', f'ChangeDisplaySettingsEx 返回码 {r}') - return - self.after(500, self.load_displays) # 等模式生效后刷新矩形/EDID 面板 - - def _restore_mode(self): - from tkinter import messagebox - idx = self.cbo.current() - if idx < 0 or idx >= len(self.displays): - return - dev = self.displays[idx]['device'] - dm = self._orig_modes.pop(dev, None) - if dm is None: - messagebox.showinfo('无可恢复', '该显示器没有记录切改前的模式') - return - apply_mode(dev, dm) - self.after(500, self.load_displays) - - def _show_pattern(self, kind): - idx = self.cbo.current() - if idx < 0 or idx >= len(self.displays): - return - d = self.displays[idx] - if not d['rect']: - return - show_pattern(self, d['rect'], kind, area=self.area_var.get()) - - def _show_custom(self, idx): - from tkinter import messagebox - slot = self.slots[idx] - if not slot['file']: - messagebox.showinfo('未配置图片', - f'请先点击"⚙"为"{slot["name"]}"配置图片。') - return - path = os.path.join(BASE_DIR, slot['file']) - if not os.path.exists(path): - messagebox.showerror('图片不存在', path) - return - di = self.cbo.current() - if di < 0 or di >= len(self.displays) or not self.displays[di]['rect']: - return - show_pattern_image(self, self.displays[di]['rect'], path, - area=self.area_var.get()) - - def _configure_slot(self, idx): - from tkinter import filedialog, messagebox - dlg = tk.Toplevel(self) - dlg.title(f'配置自定义栏位 {idx + 1}') - dlg.resizable(False, False) - dlg.grab_set() - - name_var = tk.StringVar(value=self.slots[idx]['name']) - file_var = tk.StringVar(value=self.slots[idx]['file'] or '(未设置)') - - body = ttk.Frame(dlg, padding=10) - body.pack() - ttk.Label(body, text='按钮名称:').grid(row=0, column=0, sticky='w', pady=4) - ttk.Entry(body, textvariable=name_var, width=30).grid(row=0, column=1, columnspan=2, - sticky='w', padx=6) - ttk.Label(body, text='图片文件:').grid(row=1, column=0, sticky='w', pady=4) - ttk.Label(body, textvariable=file_var, foreground='#0066CC').grid( - row=1, column=1, columnspan=2, sticky='w', padx=6) - if not HAS_PIL: - ttk.Label(body, text='(未安装 Pillow, 仅支持 PNG/GIF; 安装 pillow 后可用 JPG/BMP/WebP)', - foreground='#B05000').grid(row=2, column=0, columnspan=3, sticky='w') - - def choose(): - exts = ' '.join(f'*{e}' for e in IMG_EXTS) - path = filedialog.askopenfilename(parent=dlg, title='选择图片', - filetypes=[('图片文件', exts), ('所有文件', '*.*')]) - if not path: - return - try: - file_var.set(copy_image_into_script_dir(path)) - except OSError as ex: - messagebox.showerror('复制图片失败', str(ex), parent=dlg) - - def clear(): - file_var.set('(未设置)') - - def save(): - name = name_var.get().strip() or f'自定义{idx + 1}' - fname = file_var.get() - self.slots[idx] = {'name': name, - 'file': None if fname.startswith('(') else fname} - save_slots(self.slots, self.area_var.get()) - self.slot_buttons[idx].configure(text=name) - dlg.destroy() - - row = 3 if not HAS_PIL else 2 - ttk.Button(body, text='选择图片...', command=choose).grid( - row=row, column=0, sticky='w', pady=(8, 0)) - ttk.Button(body, text='清除图片', command=clear).grid( - row=row, column=1, sticky='w', padx=6, pady=(8, 0)) - ttk.Button(body, text='保存', command=save).grid( - row=row + 1, column=0, sticky='w', pady=(8, 0)) - ttk.Button(body, text='取消', command=dlg.destroy).grid( - row=row + 1, column=1, sticky='w', padx=6, pady=(8, 0)) - - app = App() - if auto_close > 0: - app.after(auto_close * 1000, app.destroy) - app.mainloop() + """启动 CustomTkinter 主界面(实现在 app_gui.py)""" + import app_gui + app_gui.run_gui(auto_close) -# ============================================================ -# 入口 -# ============================================================ def run_dump(edid_fields=False): displays = get_displays() if not displays: diff --git a/HDMI-Tool/src/patterns.py b/HDMI-Tool/src/patterns.py new file mode 100644 index 0000000..ebe3cdd --- /dev/null +++ b/HDMI-Tool/src/patterns.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +测试图案全屏投屏窗口(tkinter): 纯色/棋盘格/网格线 + 自定义图片 1:1 居中 +================================================================ +投屏窗口为无边框黑底 Toplevel, ESC 或点击退出; 测试卡区域可选 +640x360 / 800x600 / full 铺满(常量与图片加载配置在 config)。 +""" + +from config import DEFAULT_AREA, HAS_PIL, Image, ImageTk, PAT_W, PAT_H + + +PATTERN_NAMES = {'Red': '纯红', 'Green': '纯绿', 'Blue': '纯蓝', + 'Checker': '黑白棋盘格', 'Grid': '网格线'} +# 纯色测试值(位交换测试用, 与 assets/solid_*.png 对应): +# 每通道 55/AA 互为镜像对(检测对), FF 全高基准 +SOLID_ROWS = ( + ('R', (('55', '#550000'), ('AA', '#AA0000'), ('FF', '#FF0000'))), + ('G', (('55', '#005500'), ('AA', '#00AA00'), ('FF', '#00FF00'))), + ('B', (('55', '#000055'), ('AA', '#0000AA'), ('FF', '#0000FF'))), +) + + +def load_image_raw(path): + """按原始像素加载图片(不缩放不裁剪; PIL 仅用于支持 JPG/BMP/WebP 等格式)""" + import tkinter as tk + if HAS_PIL: + return ImageTk.PhotoImage(Image.open(path).convert('RGB')) + return tk.PhotoImage(file=path) + + +def _area_box(spec, w, h): + """区域选项 -> (x0, y0, aw, ah): 'full' 铺满整个屏幕, 'WxH' 黑底居中""" + if spec == 'full': + return 0, 0, w, h + try: + aw, ah = (int(v) for v in spec.lower().split('x', 1)) + except Exception: + aw, ah = PAT_W, PAT_H + return (w - aw) // 2, (h - ah) // 2, aw, ah + + +def _open_fullscreen(parent, rect, auto_close=0): + """创建覆盖 rect(x,y,w,h) 的无边框全屏黑底窗口, 返回 (top, cv) + + 只支持 ESC 退出: overrideredirect 窗口必须 focus_force 强取键盘焦点, + 否则收不到按键(Windows 上该组合有效)。 + """ + import tkinter as tk + rx, ry, rw, rh = rect + top = tk.Toplevel(parent) + top.overrideredirect(True) + top.geometry(f'{rw}x{rh}+{rx}+{ry}') + top.attributes('-topmost', True) + top.configure(bg='black') + + cv = tk.Canvas(top, bg='black', highlightthickness=0, bd=0) + cv.pack(fill='both', expand=True) + + def close(_evt=None): + top.destroy() + + top.bind('', close) + if auto_close > 0: + top.after(auto_close * 1000, close) + top.focus_force() + return top, cv + + +def show_pattern(parent, rect, kind, auto_close=0, area=DEFAULT_AREA): + """在 rect 指定的屏幕上全屏出居中测试卡(无任何 OSD 修饰), 区域由 area 指定""" + top, cv = _open_fullscreen(parent, rect, auto_close) + + def draw(_evt=None): + cv.delete('all') + w, h = max(cv.winfo_width(), rect[2]), max(cv.winfo_height(), rect[3]) + x0, y0, aw, ah = _area_box(area, w, h) + + if kind.startswith('#'): # 纯色测试值: 直接填充指定颜色 + cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill=kind, outline='') + elif kind == 'Red': + cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill='#FF0000', outline='') + elif kind == 'Green': + cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill='#00FF00', outline='') + elif kind == 'Blue': + cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill='#0000FF', outline='') + elif kind == 'Checker': + # 40px 黑白格, 格数随区域尺寸自适应(640x360 = 16x9 格) + for gy in range((ah + 39) // 40): + for gx in range((aw + 39) // 40): + if (gx + gy) % 2 == 0: + cv.create_rectangle(x0 + gx * 40, y0 + gy * 40, + x0 + (gx + 1) * 40, y0 + (gy + 1) * 40, + fill='#FFFFFF', outline='') + elif kind == 'Grid': + # 20px 白色网格线, 黑底, 无其它修饰 + for k in range(aw // 20 + 1): + cv.create_line(x0 + k * 20, y0, x0 + k * 20, y0 + ah, fill='#FFFFFF') + for k in range(ah // 20 + 1): + cv.create_line(x0, y0 + k * 20, x0 + aw, y0 + k * 20, fill='#FFFFFF') + + top.bind('', draw) + top.after(50, draw) + top.wait_window(top) + + +def show_pattern_image(parent, rect, path, auto_close=0, area=DEFAULT_AREA): + """全屏黑底, 图片 1:1 居中于 area 指定的测试卡区域: + 小于区域 -> 真实像素居中, 四周黑边; + 大于区域 -> 等效从中心截取(区域外用黑矩形遮挡)""" + top, cv = _open_fullscreen(parent, rect, auto_close) + + def draw(_evt=None): + cv.delete('all') + w, h = max(cv.winfo_width(), rect[2]), max(cv.winfo_height(), rect[3]) + x0, y0, aw, ah = _area_box(area, w, h) + photo = load_image_raw(path) + top._photo = photo # 挂在窗口上防止被垃圾回收 + iw, ih = photo.width(), photo.height() + cv.create_image(x0 + (aw - iw) // 2, y0 + (ah - ih) // 2, + image=photo, anchor='nw') + # 四块黑矩形盖住测试卡区域之外 -> 大图等效中心裁剪 + cv.create_rectangle(-2, -2, w + 2, y0, fill='black', outline='') + cv.create_rectangle(-2, y0 + ah, w + 2, h + 2, fill='black', outline='') + cv.create_rectangle(-2, y0, x0, y0 + ah, fill='black', outline='') + cv.create_rectangle(x0 + aw, y0, w + 2, y0 + ah, fill='black', outline='') + + top.bind('', draw) + top.after(50, draw) + top.wait_window(top) + diff --git a/README.md b/README.md index d3f7693..829728b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套上位机工 ## 环境要求 - 发布版 zip 开箱即用(Windows 10/11 x64,无需安装 Python) -- 源码运行:Python ≥3.10;HDMI-Tool 为纯标准库(自定义图片 JPG/BMP 需可选 Pillow); +- 源码运行:Python ≥3.10;HDMI-Tool 命令行/EDID 纯标准库,GUI 需 + `py -m pip install customtkinter`(自定义图片 JPG/BMP 可选 Pillow、光机控制可选 pyserial); I2C-Inject 需 `py -m pip install pyserial` - 重新打包:`HDMI-Tool/src/build_exe.py`(自动安装 PyInstaller);I2C-Inject 目录内 `python build_exe.py` From bb8efa739297bdb15a051a274bac22c0d6d16837 Mon Sep 17 00:00:00 2001 From: hulk Date: Sat, 12 Sep 2026 01:42:01 +0800 Subject: [PATCH 02/10] =?UTF-8?q?style(HDMI-Tool):=20=E6=8C=89=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=A0=87=E8=AE=B0=E5=BE=AE=E8=B0=83=20CTk=20=E7=95=8C?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HDMI-Tool/release/README.md | 52 ++++++++++++--------- HDMI-Tool/src/app_gui.py | 92 ++++++++++++++++++++++++++----------- 2 files changed, 97 insertions(+), 47 deletions(-) diff --git a/HDMI-Tool/release/README.md b/HDMI-Tool/release/README.md index d56f61b..602ea1a 100644 --- a/HDMI-Tool/release/README.md +++ b/HDMI-Tool/release/README.md @@ -20,7 +20,8 @@ | EDID 逐字节解析 | "EDID 原始数据"框右上"逐字节解析"按钮:按偏移逐字段展开基础块与 CTA-861 扩展块,每字段附原始字节 hex;命令行等价 `--edid-fields` | | 分辨率切换 | "分辨率"下拉列出显卡按该屏 EDID 提供的全部模式;**应用**=临时切换(不写注册表,重启/恢复即还原),**恢复**=回到本次会话切改前的模式 | | 测试图案投屏 | 全屏投到所选显示器,测试卡区域可选 `640x360 / 800x600 / full 铺满`;内置 纯红/纯绿/纯蓝/黑白棋盘格/网格线 + R/G/B 三行 55/AA/FF 纯色值按钮 + 3 个自定义图片栏位 | -| 光机控制 | RS485 → 板载 GD32E230 网关 → DLPC3421:开关光机(可定时)、设光强、查电流/温度/版本、TEC 控制、软件重启;收发日志带协议译码(见 §5) | +| 界面 | CustomTkinter 深色双栏仪表盘(左列 HDMI 源端 / 右列 光机控制),顶栏可切换 Dark/Light/System | +| 光机控制 | RS485 → 板载 GD32E230 网关 → DLPC3421:开关光机(可定时)、设光强、查电流/温度/版本、TEC 控制、软件重启;光强/定时提供快捷滑条;收发日志带协议译码(见 §5) | | 智能显示拓扑 | 仅当检测到转接板(EDID 厂商 "XLS")接入但未进入桌面(Windows 卡在"仅电脑屏幕")时才强制扩展把它拉回;你主动选的"仅第二屏幕"等拓扑不会被改动 | | 命令行模式 | 全部功能可脚本化调用,供自动化测试(见 §6、§7) | | 波形分析脚本 | 逻辑分析仪 CSV 波形按占空比签名分段,输出行结构/占空比/消隐明细(见 §7.3) | @@ -45,24 +46,25 @@ HDMI-Tool/ ## 3. GUI 使用 -主窗口自上而下四块区域: +主窗口为双栏仪表盘(布局设计稿:`UI参考布局.svg`),默认深色外观,顶栏可切换 Dark/Light/System: ``` -┌ 显示器: [ ... ▼] [刷新] ┐ -├ 分辨率: [800x600@60 ▼] [应用] [恢复] ├ 显示器/EDID -│ 支持的模式(EDID 解算) | EDID 信息 ┘ -├ 测试图案 — 图案按钮 / R,G,B 0x55,0xAA,0xFF / 测试卡区域 / 自定义栏位 ┐ -├ 光机控制 — 串口 [连接] / 开关光机 / 光强 / 命令下拉 / 自定义命令 ┘ 投屏+光机 -│ └ 收发日志(带协议译码与原始 hex) -└ EDID 原始数据 — hex dump [逐字节解析] ┘ +左列(HDMI 源端) 右列(光机控制 RS485) +┌ 显示器选择区域 [刷新] ┐ ┌ 串口选择 + 波特率 / 连接状态 ┐ +├ 支持的模式: 分辨率[应用][恢复] + 明细 ┐ ├ 串口数据 — 收发日志(译码+hex) ┤ +├ EDID 信息(表格) ┘ ├ 光机命令 — 快捷按钮 │ +├ 测试图案 / 纯色 / 自定义 / 测试卡区域 │ 预设下拉 / 自定义命令输入 │ +├ EDID 原始数据 [逐字节解析] ┘ └ 快捷滑条 — 光强 / 定时投光(秒) ┘ ``` -1. 顶部显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举; -2. **切分辨率**:选模式 → "应用";测完点"恢复"; -3. **投测试图**:先选**测试卡区域**(640x360 / 800x600 / full)→ 点图案按钮全屏投放, - 按 **ESC** 或点击退出;R/G/B 三行纯色按钮(55/AA/FF)用于 RGB 位交换类测试 - (55 与 AA 互为字节镜像对,FF 全高基准);自定义图片点"⚙"配置(图片自动复制到 exe 旁); -4. 区域与自定义栏位配置自动保存在 exe 旁的 `pattern_slots.json`。 +1. 左上显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举; +2. **切分辨率**:"支持的模式"卡片里选模式 → "应用";测完点"恢复"; +3. **投测试图**:"测试图案"卡片先选**测试卡区域**(640x360 / 800x600 / full)→ + 点图案按钮全屏投放,按 **ESC** 或点击退出;R/G/B 纯色按钮(55/AA/FF,按通道着色) + 用于 RGB 位交换类测试;自定义图片点"⚙"配置(图片自动复制到 exe 旁); +4. **光机控制**:右列选串口 → 连接 → 快捷按钮/预设下拉/自定义输入(回车即发); + 快捷滑条直接拖光强后"下发 M731S",拖秒数后"开光机(定时)"(0 秒=不限时 M730S0); +5. 测试卡区域、自定义栏位与串口记忆自动保存在 exe 旁的 `pattern_slots.json`。 ## 4. 典型测试场景 @@ -177,8 +179,13 @@ python la_wave_analyzer3.py # 3 通道(RGB 三组同拍, ch8/ ``` HDMI-Tool/ ├── src/ # 源代码(人工维护区) -│ ├── hdmi_gui.py # 主程序:GUI + 命令行(标准库; pyserial/Pillow 可选) -│ ├── dlpc_rs485.py # 光机 RS485 协议与串口链路(帧/解析/译码/线程) +│ ├── hdmi_gui.py # 入口与命令行模式(argparse 分发) +│ ├── app_gui.py # CustomTkinter 主界面(双栏仪表盘) +│ ├── display_win.py # Win32 显示器枚举/分辨率/智能拓扑(纯标准库) +│ ├── edid_parse.py # EDID 1.4/CEA-861 解析(纯标准库) +│ ├── patterns.py # 测试图案全屏投屏窗口 +│ ├── config.py # 运行目录与 pattern_slots.json 配置 +│ ├── dlpc_rs485.py # 光机 RS485 协议与串口链路 │ ├── selftest_rs485.py # 光机协议自检(无硬件回归) │ ├── build_exe.py # PyInstaller 打包 → release/ │ ├── package_release.py # 组装发布 zip(exe+图案+说明) @@ -197,18 +204,21 @@ HDMI-Tool/ │ ├── *.png / README.md # 附属文件平铺副本(由 package_release.py 从 assets/ 同步) │ └── *.zip # 发布包(不入库, 上传 Gitea Release) ├── README.md # 本说明 -└── AGENTS.md # 面向 AI/Agent 的工程档案(结构/约定/构建规程) +├── AGENTS.md # 面向 AI/Agent 的工程档案(结构/约定/构建规程) +└── UI参考布局.svg/.png # 界面布局设计稿(app_gui.py 按此实现) ``` -从源码运行:`python src/hdmi_gui.py`,需 Python ≥3.10,标准库即可跑 EDID/投屏; -光机控制需 pyserial(`py -m pip install pyserial`),自定义图片要 JPG/BMP/WebP 需可选 Pillow。 +从源码运行:`python src/hdmi_gui.py`,需 Python ≥3.10;EDID/命令行纯标准库; +GUI 需 customtkinter(`py -m pip install customtkinter`);光机控制需 pyserial; +自定义图片要 JPG/BMP/WebP 需可选 Pillow。 ## 9. 构建与发布 ```powershell cd src python selftest_rs485.py # 光机协议回归(改 dlpc_rs485.py 后必跑) -python build_exe.py # 打包 → ../release/HDMI-Tool.exe(自动装 PyInstaller; 内置 pyserial) +python build_exe.py # 打包 → ../release/HDMI-Tool.exe(自动装 PyInstaller; + # 内置 pyserial/customtkinter, CTk 主题数据自动收集) python build_exe.py --debug # 额外出一个带控制台的调试变体(--dump 可见输出) python package_release.py 0.2.0 # 组装 release/HDMI-Tool-v0.2.0-win64.zip python publish_gitea.py HDMI-Tool-v0.2.0-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN) diff --git a/HDMI-Tool/src/app_gui.py b/HDMI-Tool/src/app_gui.py index db34500..d6ed68d 100644 --- a/HDMI-Tool/src/app_gui.py +++ b/HDMI-Tool/src/app_gui.py @@ -111,8 +111,8 @@ class App(ctk.CTk): ctk.set_appearance_mode(mode) self._apply_tv_colors() - def _card(self, parent, title, button=None, command=None): - """卡片容器: 圆角边框 + 标题行(可带右侧小按钮), 返回 (卡片, 内容 frame) + def _card(self, parent, title, button=None, command=None, right_label=None): + """卡片容器: 圆角边框 + 标题行(可带右侧小按钮/右对齐标签), 返回 (卡片, 内容 frame) 卡片自身用 grid 参与列布局; 卡片内部(head/content/行控件)一律用 pack。 """ @@ -121,6 +121,9 @@ class App(ctk.CTk): head = ctk.CTkFrame(card, fg_color='transparent') head.pack(fill='x', padx=12, pady=(8, 0)) ctk.CTkLabel(head, text=title, font=BOLD, anchor='w').pack(side='left') + if right_label is not None: + ctk.CTkLabel(head, textvariable=right_label, + text_color='gray60').pack(side='right') if button: ctk.CTkButton(head, text=button, width=92, height=24, command=command).pack(side='right') @@ -141,8 +144,8 @@ class App(ctk.CTk): body = ctk.CTkFrame(self, fg_color='transparent') body.pack(fill='both', expand=True, padx=12, pady=(4, 12)) - body.grid_columnconfigure(0, weight=11, uniform='col') - body.grid_columnconfigure(1, weight=9, uniform='col') + body.grid_columnconfigure(0, weight=1, uniform='col') + body.grid_columnconfigure(1, weight=1, uniform='col') body.grid_rowconfigure(0, weight=1) left = ctk.CTkFrame(body, fg_color='transparent') left.grid(row=0, column=0, sticky='nsew') @@ -160,24 +163,22 @@ class App(ctk.CTk): ).pack(expand=True) def _build_left(self, col): - col.grid_rowconfigure(1, weight=2) # 支持的模式 - col.grid_rowconfigure(2, weight=3) # EDID 信息 + col.grid_rowconfigure(1, weight=3) # 支持的模式 + col.grid_rowconfigure(2, weight=2) # EDID 信息 col.grid_rowconfigure(4, weight=1) # EDID 原始数据 col.grid_columnconfigure(0, weight=1) # -- 显示器选择区域 -- - card, c = self._card(col,'显示器选择区域') + self.lbl_cur_var = tk.StringVar(value='当前输出: —') + card, c = self._card(col, '显示器选择区域', right_label=self.lbl_cur_var) card.grid(row=0, column=0, sticky='ew') row = ctk.CTkFrame(c, fg_color='transparent') row.pack(fill='x') - self.cbo = ctk.CTkComboBox(row, width=380, state='readonly', + self.cbo = ctk.CTkComboBox(row, state='readonly', command=lambda _=None: self._on_select()) - self.cbo.pack(side='left') + self.cbo.pack(side='left', fill='x', expand=True) ctk.CTkButton(row, text='刷新', width=64, command=self._load_displays).pack(side='left', padx=(6, 0)) - self.lbl_cur = ctk.CTkLabel(c, text='当前输出: —', anchor='w', - text_color='gray60') - self.lbl_cur.pack(fill='x', pady=(6, 0)) # -- 支持的模式 -- card, c = self._card(col,'支持的模式 (EDID 解算)') @@ -193,7 +194,7 @@ class App(ctk.CTk): command=self._restore_mode).pack(side='left', padx=(6, 0)) ctk.CTkLabel(row, text='临时切换, 重启还原', text_color='gray50').pack(side='left', padx=8) - self.txt_modes = ctk.CTkTextbox(c, font=MONO, height=100) + self.txt_modes = ctk.CTkTextbox(c, font=MONO, height=150) self.txt_modes.pack(fill='both', expand=True, pady=(8, 0)) self.txt_modes.configure(state='disabled') @@ -245,11 +246,12 @@ class App(ctk.CTk): btn = ctk.CTkButton(cell, text=self.slots[i]['name'], width=96, command=lambda i=i: self._show_custom(i)) btn.pack(side='left') - ctk.CTkButton(cell, text='⚙', width=30, height=24, + ctk.CTkButton(cell, text='\uE713', width=30, height=24, + font=('Segoe MDL2 Assets', 13), command=lambda i=i: self._configure_slot(i) ).pack(side='left', padx=(4, 0)) self.slot_buttons.append(btn) - ctk.CTkLabel(row3, text='点击投图, ⚙ 配置图片', + ctk.CTkLabel(row3, text='点击投图, 齿轮按钮配置图片', text_color='gray50').pack(side='left', padx=4) # -- EDID 原始数据 -- @@ -338,23 +340,31 @@ class App(ctk.CTk): rowa = ctk.CTkFrame(c, fg_color='transparent') rowa.pack(fill='x') ctk.CTkLabel(rowa, text='光强(54-800):').pack(side='left') - self.var_light = tk.IntVar(value=100) + self.var_light = tk.StringVar(value='100') sld = ctk.CTkSlider(rowa, from_=54, to=800, number_of_steps=149, - command=lambda v: self.var_light.set(int(round(float(v))))) + command=lambda v: self.var_light.set(str(int(round(float(v)))))) sld.set(100) sld.pack(side='left', fill='x', expand=True, padx=8) - ctk.CTkLabel(rowa, textvariable=self.var_light, width=40).pack(side='left') + self.sld_light = sld + self.ent_light = ctk.CTkEntry(rowa, width=56, justify='center', + textvariable=self.var_light) + self.ent_light.pack(side='left') + self.ent_light.bind('', self._light_entry_apply) ctk.CTkButton(rowa, text='下发 M731S', width=110, command=self._dlpc_send_light).pack(side='left', padx=(6, 0)) rowb = ctk.CTkFrame(c, fg_color='transparent') rowb.pack(fill='x', pady=(8, 0)) ctk.CTkLabel(rowb, text='定时投光(秒):').pack(side='left') - self.var_timer = tk.IntVar(value=2) + self.var_timer = tk.StringVar(value='2') sld_t = ctk.CTkSlider(rowb, from_=0, to=32, number_of_steps=32, - command=lambda v: self.var_timer.set(int(round(float(v))))) + command=lambda v: self.var_timer.set(str(int(round(float(v)))))) sld_t.set(2) sld_t.pack(side='left', fill='x', expand=True, padx=8) - ctk.CTkLabel(rowb, textvariable=self.var_timer, width=40).pack(side='left') + self.sld_timer = sld_t + self.ent_timer = ctk.CTkEntry(rowb, width=56, justify='center', + textvariable=self.var_timer) + self.ent_timer.pack(side='left') + self.ent_timer.bind('', self._timer_entry_apply) ctk.CTkButton(rowb, text='开光机(定时)', width=110, command=self._dlpc_send_timer).pack(side='left', padx=(6, 0)) @@ -384,7 +394,7 @@ class App(ctk.CTk): self.cbo.configure(values=self._disp_labels) if not self.displays: self._cur_label = '' - self.lbl_cur.configure(text='未找到活动显示器') + self.lbl_cur_var.set('未找到活动显示器') return idx = self._disp_labels.index(prev) if prev in self._disp_labels else 0 self._cur_label = self._disp_labels[idx] @@ -396,7 +406,7 @@ class App(ctk.CTk): return self._cur_label = self.cbo.get() d = self._cur_display() - self.lbl_cur.configure(text=f"当前输出: {d['cur_mode']}") + self.lbl_cur_var.set(f"当前输出: {d['cur_mode']}") keys, modes = display_win.enum_modes(d['device']) self._modes = modes self.cbo_mode.configure(values=keys) @@ -474,7 +484,7 @@ class App(ctk.CTk): def _show_custom(self, idx): slot = self.slots[idx] if not slot['file']: - messagebox.showinfo('未配置图片', f'请先点击"⚙"为"{slot["name"]}"配置图片。') + messagebox.showinfo('未配置图片', f'请先点击齿轮按钮为"{slot["name"]}"配置图片。') return path = os.path.join(config.BASE_DIR, slot['file']) if not os.path.exists(path): @@ -671,15 +681,45 @@ class App(ctk.CTk): if cmd: self._dlpc_send(cmd) + def _light_entry_apply(self, _evt=None): + """滑条数值输入框回车: 校验并限幅 54-800, 同步回滑条""" + try: + v = int(self.var_light.get()) + except ValueError: + self.var_light.set(str(int(float(self.sld_light.get())))) + return + v = max(54, min(800, v)) + self.sld_light.set(v) + self.var_light.set(str(v)) + + def _timer_entry_apply(self, _evt=None): + """定时滑条数值输入框回车: 校验并限幅 0-32, 同步回滑条""" + try: + v = int(self.var_timer.get()) + except ValueError: + self.var_timer.set(str(int(float(self.sld_timer.get())))) + return + v = max(0, min(32, v)) + self.sld_timer.set(v) + self.var_timer.set(str(v)) + def _dlpc_send_light(self): - v = int(self.var_light.get()) + try: + v = int(self.var_light.get()) + except ValueError: + messagebox.showinfo('光强非法', '光强需为 54-800 的整数') + return if not 54 <= v <= 800: messagebox.showinfo('光强越界', '光强范围 54-800(越界固件会回 err:OL)') return self._dlpc_send(f'M731S{v}') def _dlpc_send_timer(self): - v = int(self.var_timer.get()) + try: + v = int(self.var_timer.get()) + except ValueError: + messagebox.showinfo('定时非法', '定时投光需为 0-32 的整数秒') + return self._dlpc_send('M730S0' if v <= 0 else f'M730S0T{v * 1000}') def destroy(self): From aff31656ed33d81e8c35885ae6348dc8930f1f28 Mon Sep 17 00:00:00 2001 From: hulk Date: Sat, 12 Sep 2026 01:50:15 +0800 Subject: [PATCH 03/10] =?UTF-8?q?style(HDMI-Tool):=20=E4=B8=B2=E5=8F=A3?= =?UTF-8?q?=E4=B8=8B=E6=8B=89=E8=B4=AF=E9=80=9A=E5=8D=A1=E7=89=87=E5=AE=BD?= =?UTF-8?q?=E5=BA=A6;=20=E5=8F=82=E6=95=B0=E8=A1=8C=E8=A1=A5=E5=85=A8=208-?= =?UTF-8?q?N-1(=E6=9D=BF=E5=8D=A1=E5=9B=BA=E4=BB=B6=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E6=A0=87=E6=B3=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HDMI-Tool/src/app_gui.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/HDMI-Tool/src/app_gui.py b/HDMI-Tool/src/app_gui.py index d6ed68d..3503d91 100644 --- a/HDMI-Tool/src/app_gui.py +++ b/HDMI-Tool/src/app_gui.py @@ -272,11 +272,11 @@ class App(ctk.CTk): row1 = ctk.CTkFrame(c, fg_color='transparent') row1.pack(fill='x') self._dlpc_ports = dlpc_rs485.DLPC485.list_ports_info() - self.cbo_dlpc = ctk.CTkComboBox(row1, width=250, state='readonly', + self.cbo_dlpc = ctk.CTkComboBox(row1, state='readonly', values=[lb for _d, lb in self._dlpc_ports]) if config.SERIAL_CFG['port']: self._dlpc_pick(config.SERIAL_CFG['port']) - self.cbo_dlpc.pack(side='left') + self.cbo_dlpc.pack(side='left', fill='x', expand=True) ctk.CTkButton(row1, text='刷新', width=64, command=self._dlpc_refresh).pack(side='left', padx=(6, 0)) self.btn_dlpc = ctk.CTkButton(row1, text='连接', width=72, @@ -289,6 +289,8 @@ class App(ctk.CTk): values=('115200', '57600', '38400', '9600')) self.cbo_baud.set(str(config.SERIAL_CFG['baud'])) self.cbo_baud.pack(side='left', padx=6) + ctk.CTkLabel(row2, text='数据位 8 · 校验 N · 停止位 1(板卡固件固定)', + text_color='gray50').pack(side='left', padx=(0, 6)) self.lbl_dlpc_st = ctk.CTkLabel(row2, text='未连接', text_color='gray60') self.lbl_dlpc_st.pack(side='left', padx=6) From cca7cbc9621e81ca7a9a9d5a89aa08024a101aa8 Mon Sep 17 00:00:00 2001 From: hulk Date: Sat, 12 Sep 2026 02:07:26 +0800 Subject: [PATCH 04/10] =?UTF-8?q?feat(HDMI-Tool):=20=E5=85=89=E6=9C=BA?= =?UTF-8?q?=E4=B8=B2=E5=8F=A3=E5=8F=82=E6=95=B0=E5=8F=AF=E9=80=89(?= =?UTF-8?q?=E9=BB=98=E8=AE=A48-N-1)=20+=20TEC=20=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E4=B8=8E=E6=B8=A9=E5=BA=A6=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HDMI-Tool/AGENTS.md | 4 +- HDMI-Tool/README.md | 18 ++++--- HDMI-Tool/release/README.md | 18 ++++--- HDMI-Tool/src/app_gui.py | 97 ++++++++++++++++++++++++++++++------- HDMI-Tool/src/config.py | 12 ++++- HDMI-Tool/src/dlpc_rs485.py | 30 +++++++++--- 6 files changed, 136 insertions(+), 43 deletions(-) diff --git a/HDMI-Tool/AGENTS.md b/HDMI-Tool/AGENTS.md index f5bb745..a0f535f 100644 --- a/HDMI-Tool/AGENTS.md +++ b/HDMI-Tool/AGENTS.md @@ -84,7 +84,9 @@ HDMI-Tool/ 与 I2C-Inject 的 Injector 同源);退出路径(关窗与 `--seconds` 自动关)都会走到 `App.destroy()`,在那里 `dlpc.close()` 收线程。 11. **pattern_slots.json 格式**:`{'area', 'slots', 'serial'}` 三键共存;load 兼容旧版 - 纯数组(无 area)与无 serial 键两种历史格式,save 永远写全三键。 + 纯数组(无 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 双栏仪表盘**(规格:`HDMI-Tool/UI参考布局.svg`):左列 HDMI 源端 五卡(显示器/模式/EDID 信息/测试图案/hex),右列光机四卡(串口/数据/命令/滑条), 顶栏 Dark/Light/System 切换(ttk.Treeview 颜色随动)。打包必须 `--collect-all diff --git a/HDMI-Tool/README.md b/HDMI-Tool/README.md index 602ea1a..1e0f5e4 100644 --- a/HDMI-Tool/README.md +++ b/HDMI-Tool/README.md @@ -50,21 +50,23 @@ HDMI-Tool/ ``` 左列(HDMI 源端) 右列(光机控制 RS485) -┌ 显示器选择区域 [刷新] ┐ ┌ 串口选择 + 波特率 / 连接状态 ┐ +┌ 显示器选择区域 [刷新] ┐ ┌ 串口选择 + 波特率/8-N-1 参数/连接 ┐ ├ 支持的模式: 分辨率[应用][恢复] + 明细 ┐ ├ 串口数据 — 收发日志(译码+hex) ┤ -├ EDID 信息(表格) ┘ ├ 光机命令 — 快捷按钮 │ -├ 测试图案 / 纯色 / 自定义 / 测试卡区域 │ 预设下拉 / 自定义命令输入 │ -├ EDID 原始数据 [逐字节解析] ┘ └ 快捷滑条 — 光强 / 定时投光(秒) ┘ +├ EDID 信息(表格) ┘ ├ 光机命令 — 快捷按钮+TEC │ +├ 测试图案 / 纯色 / 自定义 / 测试卡区域 │ 预设下拉 / 自定义 / 温度读取 │ +├ EDID 原始数据 [逐字节解析] ┘ └ 快捷滑条 — 光强/定时/TEC温度 ┘ ``` 1. 左上显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举; 2. **切分辨率**:"支持的模式"卡片里选模式 → "应用";测完点"恢复"; 3. **投测试图**:"测试图案"卡片先选**测试卡区域**(640x360 / 800x600 / full)→ 点图案按钮全屏投放,按 **ESC** 或点击退出;R/G/B 纯色按钮(55/AA/FF,按通道着色) - 用于 RGB 位交换类测试;自定义图片点"⚙"配置(图片自动复制到 exe 旁); -4. **光机控制**:右列选串口 → 连接 → 快捷按钮/预设下拉/自定义输入(回车即发); - 快捷滑条直接拖光强后"下发 M731S",拖秒数后"开光机(定时)"(0 秒=不限时 M730S0); -5. 测试卡区域、自定义栏位与串口记忆自动保存在 exe 旁的 `pattern_slots.json`。 + 用于 RGB 位交换类测试;自定义图片点齿轮按钮配置(图片自动复制到 exe 旁); +4. **光机控制**:右列选串口与参数(波特率/数据位/校验/停止位,默认 115200 8-N-1)→ 连接 → + 快捷按钮/预设下拉/自定义输入(回车即发);"查DMD温度""查LAMP温度"读温度; + 快捷滑条:光强拖动后"下发 M731S",定时秒数后"开光机(定时)"(0 秒=不限时 M730S0), + TEC 凝露点滑条/输入框(200-350 即 20.0-35.0°C)后"下发 M8S";数值框输入后回车自动限幅同步; +5. 测试卡区域、自定义栏位与串口记忆(含参数)自动保存在 exe 旁的 `pattern_slots.json`。 ## 4. 典型测试场景 diff --git a/HDMI-Tool/release/README.md b/HDMI-Tool/release/README.md index 602ea1a..1e0f5e4 100644 --- a/HDMI-Tool/release/README.md +++ b/HDMI-Tool/release/README.md @@ -50,21 +50,23 @@ HDMI-Tool/ ``` 左列(HDMI 源端) 右列(光机控制 RS485) -┌ 显示器选择区域 [刷新] ┐ ┌ 串口选择 + 波特率 / 连接状态 ┐ +┌ 显示器选择区域 [刷新] ┐ ┌ 串口选择 + 波特率/8-N-1 参数/连接 ┐ ├ 支持的模式: 分辨率[应用][恢复] + 明细 ┐ ├ 串口数据 — 收发日志(译码+hex) ┤ -├ EDID 信息(表格) ┘ ├ 光机命令 — 快捷按钮 │ -├ 测试图案 / 纯色 / 自定义 / 测试卡区域 │ 预设下拉 / 自定义命令输入 │ -├ EDID 原始数据 [逐字节解析] ┘ └ 快捷滑条 — 光强 / 定时投光(秒) ┘ +├ EDID 信息(表格) ┘ ├ 光机命令 — 快捷按钮+TEC │ +├ 测试图案 / 纯色 / 自定义 / 测试卡区域 │ 预设下拉 / 自定义 / 温度读取 │ +├ EDID 原始数据 [逐字节解析] ┘ └ 快捷滑条 — 光强/定时/TEC温度 ┘ ``` 1. 左上显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举; 2. **切分辨率**:"支持的模式"卡片里选模式 → "应用";测完点"恢复"; 3. **投测试图**:"测试图案"卡片先选**测试卡区域**(640x360 / 800x600 / full)→ 点图案按钮全屏投放,按 **ESC** 或点击退出;R/G/B 纯色按钮(55/AA/FF,按通道着色) - 用于 RGB 位交换类测试;自定义图片点"⚙"配置(图片自动复制到 exe 旁); -4. **光机控制**:右列选串口 → 连接 → 快捷按钮/预设下拉/自定义输入(回车即发); - 快捷滑条直接拖光强后"下发 M731S",拖秒数后"开光机(定时)"(0 秒=不限时 M730S0); -5. 测试卡区域、自定义栏位与串口记忆自动保存在 exe 旁的 `pattern_slots.json`。 + 用于 RGB 位交换类测试;自定义图片点齿轮按钮配置(图片自动复制到 exe 旁); +4. **光机控制**:右列选串口与参数(波特率/数据位/校验/停止位,默认 115200 8-N-1)→ 连接 → + 快捷按钮/预设下拉/自定义输入(回车即发);"查DMD温度""查LAMP温度"读温度; + 快捷滑条:光强拖动后"下发 M731S",定时秒数后"开光机(定时)"(0 秒=不限时 M730S0), + TEC 凝露点滑条/输入框(200-350 即 20.0-35.0°C)后"下发 M8S";数值框输入后回车自动限幅同步; +5. 测试卡区域、自定义栏位与串口记忆(含参数)自动保存在 exe 旁的 `pattern_slots.json`。 ## 4. 典型测试场景 diff --git a/HDMI-Tool/src/app_gui.py b/HDMI-Tool/src/app_gui.py index 3503d91..5c0e216 100644 --- a/HDMI-Tool/src/app_gui.py +++ b/HDMI-Tool/src/app_gui.py @@ -289,8 +289,18 @@ class App(ctk.CTk): values=('115200', '57600', '38400', '9600')) self.cbo_baud.set(str(config.SERIAL_CFG['baud'])) self.cbo_baud.pack(side='left', padx=6) - ctk.CTkLabel(row2, text='数据位 8 · 校验 N · 停止位 1(板卡固件固定)', - text_color='gray50').pack(side='left', padx=(0, 6)) + ctk.CTkLabel(row2, text='数据位:').pack(side='left') + self.cbo_databits = ctk.CTkComboBox(row2, width=62, values=('8', '7', '6', '5')) + self.cbo_databits.set(str(config.SERIAL_CFG['bytesize'])) + self.cbo_databits.pack(side='left', padx=(6, 0)) + ctk.CTkLabel(row2, text='校验:').pack(side='left', padx=(10, 0)) + self.cbo_parity = ctk.CTkComboBox(row2, width=54, values=('N', 'E', 'O')) + self.cbo_parity.set(str(config.SERIAL_CFG['parity'])) + self.cbo_parity.pack(side='left', padx=(6, 0)) + ctk.CTkLabel(row2, text='停止位:').pack(side='left', padx=(10, 0)) + self.cbo_stopbits = ctk.CTkComboBox(row2, width=54, values=('1', '2')) + self.cbo_stopbits.set(str(config.SERIAL_CFG['stopbits'])) + self.cbo_stopbits.pack(side='left', padx=(6, 0)) self.lbl_dlpc_st = ctk.CTkLabel(row2, text='未连接', text_color='gray60') self.lbl_dlpc_st.pack(side='left', padx=6) @@ -305,39 +315,48 @@ class App(ctk.CTk): '累加和校验; 全部命令见 README"光机控制"') # -- 光机命令 -- - card, c = self._card(col,'光机命令 — 预设与自定义') + card, c = self._card(col,'光机命令 — 预设 / 自定义 / TEC') card.grid(row=2, column=0, sticky='ew', pady=(10, 0)) row1 = ctk.CTkFrame(c, fg_color='transparent') row1.pack(fill='x') for name, cmd in (('开光机', 'M730S0'), ('关光机', 'M730S1'), - ('查电流', 'M732'), ('查版本', 'M999'), ('软件重启', 'M888')): - ctk.CTkButton(row1, text=name, width=88, + ('查电流', 'M732'), ('查版本', 'M999'), ('软件重启', 'M888'), + ('开TEC', 'M7S0'), ('关TEC', 'M7S1'), ('TEC33.0°C', 'M8S330')): + ctk.CTkButton(row1, text=name, width=68, command=lambda cc=cmd: self._dlpc_send(cc) - ).pack(side='left', padx=(0, 6)) + ).pack(side='left', padx=(0, 4)) row2 = ctk.CTkFrame(c, fg_color='transparent') row2.pack(fill='x', pady=(6, 0)) - ctk.CTkLabel(row2, text='预设:').pack(side='left') + lf2 = ctk.CTkFrame(row2, fg_color='transparent') + lf2.pack(side='left', fill='x', expand=True) + ctk.CTkLabel(lf2, text='预设:', width=52, anchor='w').pack(side='left') self._dlpc_presets = [(n, cmd) for n, cmd, _ in dlpc_rs485.PRESETS] self.cbo_dlpc_cmd = ctk.CTkComboBox( - row2, width=240, state='readonly', + lf2, state='readonly', values=[f'{n} {cmd}' for n, cmd in self._dlpc_presets]) self.cbo_dlpc_cmd.set(self._dlpc_presets[0][0] + ' ' + self._dlpc_presets[0][1]) - self.cbo_dlpc_cmd.pack(side='left', padx=6) - ctk.CTkButton(row2, text='发送', width=64, + self.cbo_dlpc_cmd.pack(side='left', fill='x', expand=True, padx=6) + ctk.CTkButton(lf2, text='发送', width=64, command=self._dlpc_send_preset).pack(side='left') + ctk.CTkButton(row2, text='查DMD温度', width=96, + command=lambda: self._dlpc_send('M201')).pack(side='right') row3 = ctk.CTkFrame(c, fg_color='transparent') row3.pack(fill='x', pady=(6, 0)) - ctk.CTkLabel(row3, text='自定义:').pack(side='left') + lf3 = ctk.CTkFrame(row3, fg_color='transparent') + lf3.pack(side='left', fill='x', expand=True) + ctk.CTkLabel(lf3, text='自定义:', width=52, anchor='w').pack(side='left') self.var_custom_cmd = tk.StringVar() - ent = ctk.CTkEntry(row3, width=180, textvariable=self.var_custom_cmd, + ent = ctk.CTkEntry(lf3, textvariable=self.var_custom_cmd, placeholder_text='如 M730S0T2000') - ent.pack(side='left', padx=6) + ent.pack(side='left', fill='x', expand=True, padx=6) ent.bind('', lambda _e: self._dlpc_send_custom()) - ctk.CTkButton(row3, text='发送', width=64, + ctk.CTkButton(lf3, text='发送', width=64, command=self._dlpc_send_custom).pack(side='left') + ctk.CTkButton(row3, text='查LAMP温度', width=96, + command=lambda: self._dlpc_send('M202')).pack(side='right') # -- 快捷滑条 -- - card, c = self._card(col,'快捷滑条 — 光强 / 定时投光') + card, c = self._card(col,'快捷滑条 — 光强 / 定时投光 / TEC 温度') card.grid(row=3, column=0, sticky='ew', pady=(10, 0)) rowa = ctk.CTkFrame(c, fg_color='transparent') rowa.pack(fill='x') @@ -369,6 +388,21 @@ class App(ctk.CTk): self.ent_timer.bind('', self._timer_entry_apply) ctk.CTkButton(rowb, text='开光机(定时)', width=110, command=self._dlpc_send_timer).pack(side='left', padx=(6, 0)) + rowc = ctk.CTkFrame(c, fg_color='transparent') + rowc.pack(fill='x', pady=(8, 0)) + ctk.CTkLabel(rowc, text='TEC凝露点(0.1°C):').pack(side='left') + self.var_tec = tk.StringVar(value='330') + sld_c = ctk.CTkSlider(rowc, from_=200, to=350, number_of_steps=150, + command=lambda v: self.var_tec.set(str(int(round(float(v)))))) + sld_c.set(330) + sld_c.pack(side='left', fill='x', expand=True, padx=8) + self.sld_tec = sld_c + self.ent_tec = ctk.CTkEntry(rowc, width=56, justify='center', + textvariable=self.var_tec) + self.ent_tec.pack(side='left') + self.ent_tec.bind('', self._tec_entry_apply) + ctk.CTkButton(rowc, text='下发 M8S', width=110, + command=self._dlpc_send_tec).pack(side='left', padx=(6, 0)) # ============================================================ # 显示器 / 分辨率 / EDID @@ -658,12 +692,16 @@ class App(ctk.CTk): return try: baud = int(self.cbo_baud.get()) + bytesize = int(self.cbo_databits.get()) + stopbits = int(self.cbo_stopbits.get()) except ValueError: - self.lbl_dlpc_st.configure(text='波特率非法', text_color='#E07A3F') + self.lbl_dlpc_st.configure(text='串口参数非法', text_color='#E07A3F') return - config.SERIAL_CFG['port'], config.SERIAL_CFG['baud'] = port, baud + parity = self.cbo_parity.get().strip().upper()[:1] or 'N' + config.SERIAL_CFG.update(port=port, baud=baud, bytesize=bytesize, + parity=parity, stopbits=stopbits) config.save_slots(self.slots, self.area_var.get()) # 记住本次串口 - self.dlpc.open(port, baud) + self.dlpc.open(port, baud, bytesize=bytesize, parity=parity, stopbits=stopbits) def _dlpc_send(self, cmd): if not self.dlpc.is_connected(): @@ -724,6 +762,29 @@ class App(ctk.CTk): return self._dlpc_send('M730S0' if v <= 0 else f'M730S0T{v * 1000}') + def _tec_entry_apply(self, _evt=None): + """TEC 温度输入框回车: 校验并限幅 200-350(0.1°C), 同步回滑条""" + try: + v = int(self.var_tec.get()) + except ValueError: + self.var_tec.set(str(int(float(self.sld_tec.get())))) + return + v = max(200, min(350, v)) + self.sld_tec.set(v) + self.var_tec.set(str(v)) + + def _dlpc_send_tec(self): + try: + v = int(self.var_tec.get()) + except ValueError: + messagebox.showinfo('TEC 温度非法', 'TEC 凝露点需为 200-350 的整数(0.1°C)') + return + if not 200 <= v <= 350: + messagebox.showinfo('TEC 温度越界', + 'TEC 凝露点范围 200-350(0.1°C), 越界固件会回 err:OL') + return + self._dlpc_send(f'M8S{v}') + def destroy(self): try: # 取消待触发的轮询回调, 避免销毁后 Tcl 报错 if self._poll_job: diff --git a/HDMI-Tool/src/config.py b/HDMI-Tool/src/config.py index 63d80e6..f7a2d9f 100644 --- a/HDMI-Tool/src/config.py +++ b/HDMI-Tool/src/config.py @@ -44,7 +44,8 @@ SLOT_CONFIG = os.path.join(BASE_DIR, 'pattern_slots.json') IMG_EXTS = ['.png', '.gif'] + (['.jpg', '.jpeg', '.bmp', '.webp'] if HAS_PIL else []) # 光机控制串口记忆(与栏位配置同存 pattern_slots.json 的 "serial" 键) -SERIAL_CFG = {'port': '', 'baud': dlpc_rs485.DEFAULT_BAUD} +SERIAL_CFG = {'port': '', 'baud': dlpc_rs485.DEFAULT_BAUD, + 'bytesize': 8, 'parity': 'N', 'stopbits': 1} def load_slots(): @@ -69,6 +70,15 @@ def load_slots(): SERIAL_CFG['baud'] = int(ser.get('baud') or dlpc_rs485.DEFAULT_BAUD) except (TypeError, ValueError): pass + try: + SERIAL_CFG['bytesize'] = int(ser.get('bytesize') or 8) + except (TypeError, ValueError): + pass + SERIAL_CFG['parity'] = str(ser.get('parity') or 'N').upper()[:1] + try: + SERIAL_CFG['stopbits'] = int(ser.get('stopbits') or 1) + except (TypeError, ValueError): + pass else: area, items = DEFAULT_AREA, data for i, item in enumerate(items[:SLOT_COUNT]): diff --git a/HDMI-Tool/src/dlpc_rs485.py b/HDMI-Tool/src/dlpc_rs485.py index dbae74b..5b7d90f 100644 --- a/HDMI-Tool/src/dlpc_rs485.py +++ b/HDMI-Tool/src/dlpc_rs485.py @@ -161,7 +161,8 @@ class DLPC485: def __init__(self, on_event, on_status): self.on_event, self.on_status = on_event, on_status self.q = queue.Queue() - self._target = None # 想态 (port, baud); None=断开 + self._target = None # 想态 (port, baud, bytesize, parity, stopbits) + self._open_key = None # 当前已打开串口的参数元组 self.ser = None self.parser = FrameParser() self._lock = threading.Lock() @@ -194,12 +195,13 @@ class DLPC485: out.append((dev, label)) return out - def open(self, port, baud=DEFAULT_BAUD): + def open(self, port, baud=DEFAULT_BAUD, bytesize=8, parity='N', stopbits=1): if not HAS_SERIAL: self.on_status('未安装 pyserial: py -m pip install pyserial') return with self._lock: - self._target = (port, int(baud)) + self._target = (port, int(baud), int(bytesize), + str(parity).upper(), int(stopbits)) def close(self): with self._lock: @@ -224,23 +226,37 @@ class DLPC485: self.q.put((cmd, frame)) def _run(self): + # 串口参数映射(pyserial 常量): 数据位/校验/停止位 + bytesizes = {8: serial.EIGHTBITS, 7: serial.SEVENBITS, + 6: serial.SIXBITS, 5: serial.FIVEBITS} + parities = {'N': serial.PARITY_NONE, 'E': serial.PARITY_EVEN, + 'O': serial.PARITY_ODD} + stopbits = {1: serial.STOPBITS_ONE, 2: serial.STOPBITS_TWO} while True: with self._lock: target = self._target - cur = (self.ser.port, self.ser.baudrate) if self.ser else None - if target != cur: # 端口/波特率变化或断开: 统一在此切换 + cur = self._open_key + if target != cur: if self.ser is not None: try: self.ser.close() except Exception: pass self.ser = None + self._open_key = None self.on_status('已断开') if target: + port, baud, dbsize, parity, sbits = target try: - self.ser = serial.Serial(target[0], target[1], timeout=0.15) + self.ser = serial.Serial( + 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) self.parser = FrameParser() - self.on_status(f'已连接 {target[0]} @ {target[1]}') + self._open_key = target + self.on_status(f'已连接 {port} @ {baud} ' + f'{dbsize}{parity}{sbits}') except Exception as e: self.ser = None with self._lock: From 19afeed294da1c36a2d39e6d814bc6d32acb239c Mon Sep 17 00:00:00 2001 From: hulk Date: Sat, 12 Sep 2026 02:19:51 +0800 Subject: [PATCH 05/10] =?UTF-8?q?style(HDMI-Tool):=20=E6=8C=89=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=A0=87=E8=AE=B0=E7=BB=9F=E4=B8=80=E5=B7=A6=E5=88=97?= =?UTF-8?q?=E9=AB=98=E5=BA=A6=E4=B8=8E=E6=B8=A9=E5=BA=A6=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HDMI-Tool/README.md | 7 +- HDMI-Tool/release/README.md | 7 +- HDMI-Tool/src/app_gui.py | 144 ++++++++++++++++++++---------------- 3 files changed, 89 insertions(+), 69 deletions(-) diff --git a/HDMI-Tool/README.md b/HDMI-Tool/README.md index 1e0f5e4..4eced41 100644 --- a/HDMI-Tool/README.md +++ b/HDMI-Tool/README.md @@ -63,9 +63,10 @@ HDMI-Tool/ 点图案按钮全屏投放,按 **ESC** 或点击退出;R/G/B 纯色按钮(55/AA/FF,按通道着色) 用于 RGB 位交换类测试;自定义图片点齿轮按钮配置(图片自动复制到 exe 旁); 4. **光机控制**:右列选串口与参数(波特率/数据位/校验/停止位,默认 115200 8-N-1)→ 连接 → - 快捷按钮/预设下拉/自定义输入(回车即发);"查DMD温度""查LAMP温度"读温度; - 快捷滑条:光强拖动后"下发 M731S",定时秒数后"开光机(定时)"(0 秒=不限时 M730S0), - TEC 凝露点滑条/输入框(200-350 即 20.0-35.0°C)后"下发 M8S";数值框输入后回车自动限幅同步; + 快捷按钮/预设下拉/自定义输入(回车即发);"查DMD温度""查LAMP温度"读温度 + (应答实时填入按钮旁的显示框);快捷滑条:光强拖动后"下发 M731S", + 定时毫秒后"开光机(定时)"(0=不限时 M730S0),TEC 凝露点滑条/输入框 + (200-350 即 20.0-35.0°C)后"下发 M8S";数值框输入后回车自动限幅同步; 5. 测试卡区域、自定义栏位与串口记忆(含参数)自动保存在 exe 旁的 `pattern_slots.json`。 ## 4. 典型测试场景 diff --git a/HDMI-Tool/release/README.md b/HDMI-Tool/release/README.md index 1e0f5e4..4eced41 100644 --- a/HDMI-Tool/release/README.md +++ b/HDMI-Tool/release/README.md @@ -63,9 +63,10 @@ HDMI-Tool/ 点图案按钮全屏投放,按 **ESC** 或点击退出;R/G/B 纯色按钮(55/AA/FF,按通道着色) 用于 RGB 位交换类测试;自定义图片点齿轮按钮配置(图片自动复制到 exe 旁); 4. **光机控制**:右列选串口与参数(波特率/数据位/校验/停止位,默认 115200 8-N-1)→ 连接 → - 快捷按钮/预设下拉/自定义输入(回车即发);"查DMD温度""查LAMP温度"读温度; - 快捷滑条:光强拖动后"下发 M731S",定时秒数后"开光机(定时)"(0 秒=不限时 M730S0), - TEC 凝露点滑条/输入框(200-350 即 20.0-35.0°C)后"下发 M8S";数值框输入后回车自动限幅同步; + 快捷按钮/预设下拉/自定义输入(回车即发);"查DMD温度""查LAMP温度"读温度 + (应答实时填入按钮旁的显示框);快捷滑条:光强拖动后"下发 M731S", + 定时毫秒后"开光机(定时)"(0=不限时 M730S0),TEC 凝露点滑条/输入框 + (200-350 即 20.0-35.0°C)后"下发 M8S";数值框输入后回车自动限幅同步; 5. 测试卡区域、自定义栏位与串口记忆(含参数)自动保存在 exe 旁的 `pattern_slots.json`。 ## 4. 典型测试场景 diff --git a/HDMI-Tool/src/app_gui.py b/HDMI-Tool/src/app_gui.py index 5c0e216..92bc80f 100644 --- a/HDMI-Tool/src/app_gui.py +++ b/HDMI-Tool/src/app_gui.py @@ -15,9 +15,10 @@ HDMI-Tool 主界面 — CustomTkinter 双栏仪表盘 import ctypes import os import queue +import re import time import tkinter as tk -from tkinter import filedialog, messagebox, ttk +from tkinter import filedialog, messagebox import customtkinter as ctk @@ -73,7 +74,7 @@ class App(ctk.CTk): self.dlpc = dlpc_rs485.DLPC485(self._dlpc_on_event, self._dlpc_on_status) self._dlpc_connected = False self._poll_job = None - self._tv_style() + self._tl_order = [] # 温度查询应答顺序: 'DMD'/'LAMP' 先到先得 self._build() self._load_displays() if dlpc_rs485.HAS_SERIAL: @@ -84,32 +85,8 @@ class App(ctk.CTk): # ============================================================ # 布局 # ============================================================ - def _tv_style(self): - """内嵌 ttk.Treeview 的暗/亮配色(跟随外观切换)""" - self._style = ttk.Style(self) - try: - self._style.theme_use('clam') - except Exception: - pass - self._apply_tv_colors() - - def _apply_tv_colors(self): - dark = ctk.get_appearance_mode() == 'Dark' - s = self._style - s.configure('HDMI.Treeview', - background='#181818' if dark else '#F4F4F4', - fieldbackground='#181818' if dark else '#F4F4F4', - foreground='#DADADA' if dark else '#1A1A1A', - rowheight=24, borderwidth=0) - s.configure('HDMI.Treeview.Heading', - background='#242424' if dark else '#E8E8E8', - foreground='#B0B0B0' if dark else '#444444', - relief='flat') - s.map('HDMI.Treeview', background=[('selected', '#2A5D8F')]) - def _set_appearance(self, mode): ctk.set_appearance_mode(mode) - self._apply_tv_colors() def _card(self, parent, title, button=None, command=None, right_label=None): """卡片容器: 圆角边框 + 标题行(可带右侧小按钮/右对齐标签), 返回 (卡片, 内容 frame) @@ -163,9 +140,9 @@ class App(ctk.CTk): ).pack(expand=True) def _build_left(self, col): - col.grid_rowconfigure(1, weight=3) # 支持的模式 + col.grid_rowconfigure(1, weight=2) # 支持的模式(与 EDID 信息等高) col.grid_rowconfigure(2, weight=2) # EDID 信息 - col.grid_rowconfigure(4, weight=1) # EDID 原始数据 + col.grid_rowconfigure(4, weight=0) # EDID 原始数据(固定高, 压缩) col.grid_columnconfigure(0, weight=1) # -- 显示器选择区域 -- @@ -199,18 +176,10 @@ class App(ctk.CTk): self.txt_modes.configure(state='disabled') # -- EDID 信息 -- - card, c = self._card(col,'EDID 信息') + card, c = self._card(col, 'EDID 信息') card.grid(row=2, column=0, sticky='nsew', pady=(10, 0)) - self.tv_info = ttk.Treeview(c, columns=('k', 'v'), show='headings', - height=8, style='HDMI.Treeview') - self.tv_info.heading('k', text='项目') - self.tv_info.heading('v', text='值') - self.tv_info.column('k', width=150, anchor='w') - self.tv_info.column('v', anchor='w') - sb = ttk.Scrollbar(c, orient='vertical', command=self.tv_info.yview) - self.tv_info.configure(yscrollcommand=sb.set) - self.tv_info.pack(side='left', fill='both', expand=True) - sb.pack(side='right', fill='y') + self.edid_scroll = ctk.CTkScrollableFrame(c, height=150) + self.edid_scroll.pack(fill='both', expand=True) # -- 测试图案 -- card, c = self._card(col,'测试图案 — 黑底 1:1 居中, 按 ESC 退出') @@ -255,11 +224,11 @@ class App(ctk.CTk): text_color='gray50').pack(side='left', padx=4) # -- EDID 原始数据 -- - card, c = self._card(col,'EDID 原始数据', + card, c = self._card(col, 'EDID 原始数据', button='逐字节解析', command=self._show_edid_fields) - card.grid(row=4, column=0, sticky='nsew', pady=(10, 0)) - self.txt_hex = ctk.CTkTextbox(c, font=MONO, wrap='none') - self.txt_hex.pack(fill='both', expand=True) + card.grid(row=4, column=0, sticky='ew', pady=(10, 0)) + self.txt_hex = ctk.CTkTextbox(c, font=MONO, wrap='none', height=130) + self.txt_hex.pack(fill='x') self.txt_hex.configure(state='disabled') def _build_right(self, col): @@ -322,13 +291,15 @@ class App(ctk.CTk): for name, cmd in (('开光机', 'M730S0'), ('关光机', 'M730S1'), ('查电流', 'M732'), ('查版本', 'M999'), ('软件重启', 'M888'), ('开TEC', 'M7S0'), ('关TEC', 'M7S1'), ('TEC33.0°C', 'M8S330')): - ctk.CTkButton(row1, text=name, width=68, + ctk.CTkButton(row1, text=name, width=64, command=lambda cc=cmd: self._dlpc_send(cc) - ).pack(side='left', padx=(0, 4)) + ).pack(side='left', fill='x', expand=True, padx=(0, 4)) row2 = ctk.CTkFrame(c, fg_color='transparent') row2.pack(fill='x', pady=(6, 0)) + row2.grid_columnconfigure(0, weight=6) # 左: 预设(约 6 份) + row2.grid_columnconfigure(1, weight=4) # 右: 温度查询+实时显示(约 4 份) lf2 = ctk.CTkFrame(row2, fg_color='transparent') - lf2.pack(side='left', fill='x', expand=True) + lf2.grid(row=0, column=0, sticky='ew') ctk.CTkLabel(lf2, text='预设:', width=52, anchor='w').pack(side='left') self._dlpc_presets = [(n, cmd) for n, cmd, _ in dlpc_rs485.PRESETS] self.cbo_dlpc_cmd = ctk.CTkComboBox( @@ -338,12 +309,20 @@ class App(ctk.CTk): self.cbo_dlpc_cmd.pack(side='left', fill='x', expand=True, padx=6) ctk.CTkButton(lf2, text='发送', width=64, command=self._dlpc_send_preset).pack(side='left') - ctk.CTkButton(row2, text='查DMD温度', width=96, - command=lambda: self._dlpc_send('M201')).pack(side='right') + rf2 = ctk.CTkFrame(row2, fg_color='transparent') + rf2.grid(row=0, column=1, sticky='ew', padx=(8, 0)) + ctk.CTkButton(rf2, text='查DMD温度', width=90, + command=self._query_dmd).pack(side='left') + self.var_dmd = tk.StringVar(value='DMD: —') + ctk.CTkLabel(rf2, textvariable=self.var_dmd, width=110, height=28, + corner_radius=6, fg_color=('#F2F2F2', '#181818'), + anchor='w').pack(side='left', fill='x', expand=True, padx=(6, 0)) row3 = ctk.CTkFrame(c, fg_color='transparent') row3.pack(fill='x', pady=(6, 0)) + row3.grid_columnconfigure(0, weight=6) + row3.grid_columnconfigure(1, weight=4) lf3 = ctk.CTkFrame(row3, fg_color='transparent') - lf3.pack(side='left', fill='x', expand=True) + lf3.grid(row=0, column=0, sticky='ew') ctk.CTkLabel(lf3, text='自定义:', width=52, anchor='w').pack(side='left') self.var_custom_cmd = tk.StringVar() ent = ctk.CTkEntry(lf3, textvariable=self.var_custom_cmd, @@ -352,8 +331,14 @@ class App(ctk.CTk): ent.bind('', lambda _e: self._dlpc_send_custom()) ctk.CTkButton(lf3, text='发送', width=64, command=self._dlpc_send_custom).pack(side='left') - ctk.CTkButton(row3, text='查LAMP温度', width=96, - command=lambda: self._dlpc_send('M202')).pack(side='right') + rf3 = ctk.CTkFrame(row3, fg_color='transparent') + rf3.grid(row=0, column=1, sticky='ew', padx=(8, 0)) + ctk.CTkButton(rf3, text='查LAMP温度', width=90, + command=self._query_lamp).pack(side='left') + self.var_lamp = tk.StringVar(value='LAMP: —') + ctk.CTkLabel(rf3, textvariable=self.var_lamp, width=110, height=28, + corner_radius=6, fg_color=('#F2F2F2', '#181818'), + anchor='w').pack(side='left', fill='x', expand=True, padx=(6, 0)) # -- 快捷滑条 -- card, c = self._card(col,'快捷滑条 — 光强 / 定时投光 / TEC 温度') @@ -375,11 +360,11 @@ class App(ctk.CTk): command=self._dlpc_send_light).pack(side='left', padx=(6, 0)) rowb = ctk.CTkFrame(c, fg_color='transparent') rowb.pack(fill='x', pady=(8, 0)) - ctk.CTkLabel(rowb, text='定时投光(秒):').pack(side='left') - self.var_timer = tk.StringVar(value='2') - sld_t = ctk.CTkSlider(rowb, from_=0, to=32, number_of_steps=32, + ctk.CTkLabel(rowb, text='定时投光(毫秒):').pack(side='left') + self.var_timer = tk.StringVar(value='2000') + sld_t = ctk.CTkSlider(rowb, from_=0, to=32700, number_of_steps=327, command=lambda v: self.var_timer.set(str(int(round(float(v)))))) - sld_t.set(2) + sld_t.set(2000) sld_t.pack(side='left', fill='x', expand=True, padx=8) self.sld_timer = sld_t self.ent_timer = ctk.CTkEntry(rowb, width=56, justify='center', @@ -450,16 +435,15 @@ class App(ctk.CTk): self.cbo_mode.set(d['cur_mode']) elif keys: self.cbo_mode.set(keys[0]) - self.tv_info.delete(*self.tv_info.get_children()) + self._clear_edid_rows() if not d['edid']: - self.tv_info.insert('', 'end', - values=('EDID', '读取失败(注册表无 EDID 数据)')) + self._add_edid_row('EDID', '读取失败(注册表无 EDID 数据)') self._set_text(self.txt_modes, '') self._set_text(self.txt_hex, '') return e = edid_parse.parse_edid(d['edid']) for k, v in edid_parse.edid_info_lines(e): - self.tv_info.insert('', 'end', values=(k, v)) + self._add_edid_row(k, v) lines = [] for k, t in enumerate(e['dtds']): src = 'DTD 首选' if k == 0 else 'DTD' @@ -474,6 +458,18 @@ class App(ctk.CTk): self._set_text(self.txt_modes, '\n'.join(lines)) 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() + + 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) + def _apply_mode(self): d = self._cur_display() if d is None or self.cbo_mode.get() not in self._modes: @@ -620,8 +616,25 @@ class App(ctk.CTk): # 光机控制(RS485) # ============================================================ def _dlpc_on_event(self, line): + # 温度查询应答: okTL:<0.1°C>, 依发送顺序归入 DMD/LAMP 实时显示框 + m = re.search(r'← okTL:(-?\d+)', line) + if m and self._tl_order: + which = self._tl_order.pop(0) + try: + text = f'{which}: {int(m.group(1)) / 10:.1f}°C' + except ValueError: + text = f'{which}: {m.group(1)}' + self._dlpc_log_q.put(('temp', text)) self._dlpc_log_q.put(('event', line)) + 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 _dlpc_on_status(self, msg): self._dlpc_log_q.put(('status', msg)) @@ -639,6 +652,11 @@ class App(ctk.CTk): else: color = 'gray60' self.lbl_dlpc_st.configure(text=text, text_color=color) + elif kind == 'temp': + if text.startswith('DMD'): + self.var_dmd.set(text) + else: + self.var_lamp.set(text) self._dlpc_log(text) except queue.Empty: pass @@ -733,13 +751,13 @@ class App(ctk.CTk): self.var_light.set(str(v)) def _timer_entry_apply(self, _evt=None): - """定时滑条数值输入框回车: 校验并限幅 0-32, 同步回滑条""" + """定时滑条数值输入框回车: 校验并限幅 0-32700(ms), 同步回滑条""" try: v = int(self.var_timer.get()) except ValueError: self.var_timer.set(str(int(float(self.sld_timer.get())))) return - v = max(0, min(32, v)) + v = max(0, min(32700, v)) self.sld_timer.set(v) self.var_timer.set(str(v)) @@ -758,9 +776,9 @@ class App(ctk.CTk): try: v = int(self.var_timer.get()) except ValueError: - messagebox.showinfo('定时非法', '定时投光需为 0-32 的整数秒') + messagebox.showinfo('定时非法', '定时投光需为 0-32700 的整数毫秒(0=不限时)') return - self._dlpc_send('M730S0' if v <= 0 else f'M730S0T{v * 1000}') + self._dlpc_send('M730S0' if v <= 0 else f'M730S0T{v}') def _tec_entry_apply(self, _evt=None): """TEC 温度输入框回车: 校验并限幅 200-350(0.1°C), 同步回滑条""" From 9a70efc6de057b4450757ad5a74ab302c4b7d5bf Mon Sep 17 00:00:00 2001 From: hulk Date: Sat, 12 Sep 2026 02:36:46 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix(HDMI-Tool):=20=E4=B8=8B=E6=8B=89?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E9=85=8D=E8=89=B2=E7=BB=9F=E4=B8=80=E4=B8=BA?= =?UTF-8?q?=E6=B7=B1=E8=89=B2=E4=B8=BB=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HDMI-Tool/src/app_gui.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/HDMI-Tool/src/app_gui.py b/HDMI-Tool/src/app_gui.py index 92bc80f..5eaccf6 100644 --- a/HDMI-Tool/src/app_gui.py +++ b/HDMI-Tool/src/app_gui.py @@ -45,9 +45,18 @@ def get_icon_path(): return p if os.path.exists(p) else None +def _apply_dropdown_theme(): + """CTk 默认下拉菜单是白底, 在深色界面里风格突兀; 改为随外观自适应的配色。 + 在创建任何控件前调用, 对全部 CTkComboBox/菜单生效。""" + ctk.ThemeManager.theme['DropdownMenu']['fg_color'] = ('#F2F2F2', '#2B2B2B') + ctk.ThemeManager.theme['DropdownMenu']['hover_color'] = ('#D8D8D8', '#3C3C3C') + ctk.ThemeManager.theme['DropdownMenu']['text_color'] = ('#1A1A1A', '#DADADA') + + def run_gui(auto_close=0): """入口: 配置外观并启动主窗口""" ctk.set_appearance_mode('Dark') + _apply_dropdown_theme() App(auto_close).mainloop() From 210e901ede78639e7e7b1d5c4f83a47950b8a913 Mon Sep 17 00:00:00 2001 From: hulk Date: Sat, 12 Sep 2026 03:05:32 +0800 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20=E4=BA=A7=E5=93=81=E6=9B=B4?= =?UTF-8?q?=E5=90=8D=20DLPilot(3D=20=E5=85=89=E5=9B=BA=E5=8C=96=E5=85=89?= =?UTF-8?q?=E6=9C=BA=E8=B0=83=E8=AF=95=E5=8F=B0)=20+=20=E6=8C=89=E6=A0=87?= =?UTF-8?q?=E8=AE=B0=E8=B0=83=E6=95=B4=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + HDMI-Tool/AGENTS.md | 26 ++++++------ HDMI-Tool/README.md | 26 ++++++------ HDMI-Tool/release/README.md | 26 ++++++------ HDMI-Tool/src/app_gui.py | 69 ++++++++++++++++++++++++++------ HDMI-Tool/src/build_exe.py | 8 ++-- HDMI-Tool/src/config.py | 2 +- HDMI-Tool/src/package_release.py | 26 ++++++------ HDMI-Tool/src/publish_gitea.py | 16 ++++---- README.md | 18 ++++----- 10 files changed, 131 insertions(+), 87 deletions(-) diff --git a/.gitignore b/.gitignore index 7bf3aac..5392d48 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ dist/ # 发布 zip: 可由 src/package_release.py 重新生成, 只上传 Gitea Release HDMI-Tool/release/*.zip +DLPilot/release/*.zip # ZCode 客户端本地会话数据(计划留档等), 非项目配置 .zcode/ diff --git a/HDMI-Tool/AGENTS.md b/HDMI-Tool/AGENTS.md index a0f535f..447a7f6 100644 --- a/HDMI-Tool/AGENTS.md +++ b/HDMI-Tool/AGENTS.md @@ -1,4 +1,4 @@ -# AGENTS.md — HDMI-Tool 工程档案(供 AI/Agent 使用) +# AGENTS.md — DLPilot 工程档案(供 AI/Agent 使用) > **本文档面向 AI/Agent**,是人类文档 [README.md](README.md) 的姊妹篇,两者不重叠: > README 回答"这是什么、怎么用";本文回答"代码怎么组织、怎么构建发布、改动时哪些约束不能破坏"。 @@ -9,17 +9,17 @@ 仓库 `LT8619C_Debug_Tools` 是 LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板的**调试上位机工具集**, 上游主仓库(固件)为 `hulk/LT8619C_DLPC3421_HDMI`(同 Gitea 实例 `gitea.hulk.wang`), 本仓库本机路径 `E:\Hulk-Coding\LT8619C_DLPC3421_HDMI`。 -HDMI-Tool 工作在两端:HDMI **源端**(PC 显卡侧,EDID/分辨率/图案)与**光机控制端** +DLPilot 工作在两端:HDMI **源端**(PC 显卡侧,EDID/分辨率/图案)与**光机控制端** (RS485 → 板载 GD32E230 网关 → DLPC3421)。寄存器语义判读基线(0x606E 位交换 / 0x606D 整组顺序)与光机协议规程都在上游仓库 `doc/architecture/` 与 `protocols.md`,不在本仓库。 ## 2. 目录契约(重要) ``` -HDMI-Tool/ +DLPilot/ ├── src/ 源代码区 —— 只有这里可以手改(logos/ 改图标源后重跑 make_ico.py) ├── assets/ 生成资产区 —— gen_*.py 的产物(PNG 图案),可随时再生成,改动需提交 -├── release/ 产物区 —— 机器生成,禁止手改:HDMI-Tool.exe、pattern_slots.json、 +├── release/ 产物区 —— 机器生成,禁止手改:DLPilot.exe、pattern_slots.json、 │ bw/solid 图案(package_release.py 从 assets/ 平铺复制来,均入库)、 │ *.zip(gitignore,只上传 Release)。release/ 本身=完整可直接运行的 │ 发布文件夹(exe+附属平铺),内容与 zip 一致 @@ -42,8 +42,8 @@ HDMI-Tool/ | `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/HDMI-Tool.exe`(onefile/noconsole, 内嵌图标+pyserial+customtkinter) | -| `src/package_release.py` | 复制图案到 release/(平铺) → `release/HDMI-Tool-v-win64.zip`(校验栏位引用完整性; release/ 与 zip 内容一致) | +| `src/build_exe.py` | PyInstaller 打包 → `release/DLPilot.exe`(onefile/noconsole, 内嵌图标+pyserial+customtkinter) | +| `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 无代码共享) | | `src/gen_bw_patterns.py` | 生成 `assets/bw_*.png` ×5(640x360 黑白,需 Pillow) | @@ -87,7 +87,7 @@ HDMI-Tool/ 纯数组(无 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 双栏仪表盘**(规格:`HDMI-Tool/UI参考布局.svg`):左列 HDMI 源端 +12. **GUI = app_gui.py 双栏仪表盘**(规格:`DLPilot/UI参考布局.svg`):左列 HDMI 源端 五卡(显示器/模式/EDID 信息/测试图案/hex),右列光机四卡(串口/数据/命令/滑条), 顶栏 Dark/Light/System 切换(ttk.Treeview 颜色随动)。打包必须 `--collect-all customtkinter`(build_exe.py 已内置,勿删)——CTk 主题/字体数据缺失会让 exe 启动即崩。 @@ -111,14 +111,14 @@ HDMI-Tool/ ```powershell python gen_bw_patterns.py ; python gen_solid_colors.py # 仅当改了图案生成逻辑 -python build_exe.py # → ../release/HDMI-Tool.exe(缺 PyInstaller 会自动 pip install) -python build_exe.py --debug # 附加带控制台的 HDMI-Tool-console.exe(调试 --dump 用) -python package_release.py <版本> # → ../release/HDMI-Tool-v<版本>-win64.zip -python publish_gitea.py HDMI-Tool-v<版本>-win64.zip --replace --insecure # 上传(需 GITEA_TOKEN) +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) ``` 发布约定: -- tag/release 命名 `HDMI-Tool-vX.Y.Z`,zip 命名 `HDMI-Tool-vX.Y.Z-win64.zip`(publish_gitea.py +- tag/release 命名 `DLPilot-vX.Y.Z`,zip 命名 `DLPilot-vX.Y.Z-win64.zip`(publish_gitea.py 依赖此命名从文件名推 tag); - **exe、pattern_slots.json 与 release/ 下平铺的图案 png 改动跟随源码/资产生成提交入库; zip 永不入库**(.gitignore 已挡); @@ -151,5 +151,5 @@ python publish_gitea.py HDMI-Tool-v<版本>-win64.zip --replace --insecure # 4. `python hdmi_gui.py --test-pattern Checker --area 800x600 --seconds 3` —— 投图链路; 5. `python hdmi_gui.py --dump` —— EDID 解算不抛异常; 6. `python hdmi_gui.py --dlpc-cmd M999`(不带 --port)—— 应明确报错而非崩溃(无硬件时的优雅降级); -7. `python build_exe.py` → 双击 release/HDMI-Tool.exe 确认图标/双栏布局/投图/光机区渲染; +7. `python build_exe.py` → 双击 release/DLPilot.exe 确认图标/双栏布局/投图/光机区渲染; 8. `python package_release.py <版本>` —— 不报"引用图片缺失"即栏位完整性 OK。 diff --git a/HDMI-Tool/README.md b/HDMI-Tool/README.md index 4eced41..3afd3b1 100644 --- a/HDMI-Tool/README.md +++ b/HDMI-Tool/README.md @@ -1,11 +1,11 @@ -# HDMI-Tool — HDMI 源端调试 + 光机控制工具 +# DLPilot — HDMI 源端调试 + 光机控制工具 > LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套的 PC 端工具: > HDMI **源端**(EDID 查看、分辨率切换、纯色/测试图案投屏)+ **光机控制** > (RS485 直连板上 GD32 网关,开关光机/设光强/查温度电流)。 > > - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases) -> 下载 `HDMI-Tool-vX.Y.Z-win64.zip`,解压双击 `HDMI-Tool.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。 +> 下载 `DLPilot-vX.Y.Z-win64.zip`,解压双击 `DLPilot.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。 > - **要改代码/重新打包**:见 §8、§9。 > > 更新:2026-09-11 @@ -29,15 +29,15 @@ ## 2. 下载与安装 1. 打开 [Releases 页面](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases),取最新的 - `HDMI-Tool-vX.Y.Z-win64.zip`; + `DLPilot-vX.Y.Z-win64.zip`; 2. 解压到**任意可写目录**(自定义图片与栏位配置会写到 exe 旁边); -3. 双击 `HDMI-Tool.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。 +3. 双击 `DLPilot.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。 包内包含: ``` -HDMI-Tool/ -├── HDMI-Tool.exe 主程序(单文件自包含, 光机控制的 pyserial 已内置) +DLPilot/ +├── DLPilot.exe 主程序(单文件自包含, 光机控制的 pyserial 已内置) ├── pattern_slots.json 预置自定义栏位 + 串口记忆 ├── bw_*.png ×5 640x360 黑白测试图 ├── solid_*.png ×10 800x600 纯色测试图 @@ -72,7 +72,7 @@ HDMI-Tool/ ## 4. 典型测试场景 ``` -1. HDMI 接转接板 → 启动 HDMI-Tool → 刷新 → 选中板卡显示器 +1. HDMI 接转接板 → 启动 DLPilot → 刷新 → 选中板卡显示器 2. 板卡 shadow EDID 首选 640x360@60 → 测试卡区域选 640x360(与光机面板原生 1:1) 3. "光机控制"连接串口 → M999 查版本确认链路通 → M730S0 开光机 4. 点 R/G/B 行纯色按钮投图 → 目视/逻辑分析仪核对 RGB888 输出 @@ -180,7 +180,7 @@ python la_wave_analyzer3.py # 3 通道(RGB 三组同拍, ch8/ ## 8. 目录结构与从源码运行 ``` -HDMI-Tool/ +DLPilot/ ├── src/ # 源代码(人工维护区) │ ├── hdmi_gui.py # 入口与命令行模式(argparse 分发) │ ├── app_gui.py # CustomTkinter 主界面(双栏仪表盘) @@ -202,7 +202,7 @@ HDMI-Tool/ │ ├── bw_*.png ×5 # 640x360 黑白系列 │ └── solid_*.png ×10 # 800x600 纯色系列 ├── release/ # 构建与发布产物(机器生成区, 不手改) -│ ├── HDMI-Tool.exe # 打包单文件版(改动跟随源码提交入库) +│ ├── DLPilot.exe # 打包单文件版(改动跟随源码提交入库) │ ├── pattern_slots.json # exe 运行配置(栏位预置 + 串口记忆) │ ├── *.png / README.md # 附属文件平铺副本(由 package_release.py 从 assets/ 同步) │ └── *.zip # 发布包(不入库, 上传 Gitea Release) @@ -220,11 +220,11 @@ GUI 需 customtkinter(`py -m pip install customtkinter`);光机控制需 p ```powershell cd src python selftest_rs485.py # 光机协议回归(改 dlpc_rs485.py 后必跑) -python build_exe.py # 打包 → ../release/HDMI-Tool.exe(自动装 PyInstaller; +python build_exe.py # 打包 → ../release/DLPilot.exe(自动装 PyInstaller; # 内置 pyserial/customtkinter, CTk 主题数据自动收集) python build_exe.py --debug # 额外出一个带控制台的调试变体(--dump 可见输出) -python package_release.py 0.2.0 # 组装 release/HDMI-Tool-v0.2.0-win64.zip -python publish_gitea.py HDMI-Tool-v0.2.0-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN) +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) ``` ## 10. 注意事项 / FAQ @@ -235,6 +235,6 @@ python publish_gitea.py HDMI-Tool-v0.2.0-win64.zip --insecure # 上传 Gitea R - **exe 放只读目录会怎样**:自定义图片与 `pattern_slots.json` 写在 exe 旁边,只读目录会导致配置存不下; - **重新打包后图标没变**:Windows 图标缓存,`ie4uinit -show` 或改个名即可; - **多屏序号**:切分辨率前先确认显示器序号(GUI 下拉框与 `hdmi_source.bat -List` 输出一致); -- **残留进程**:修改源码重新打包前先结束残留的 HDMI-Tool 进程(`build_exe.py` 会自动 taskkill); +- **残留进程**:修改源码重新打包前先结束残留的 DLPilot 进程(`build_exe.py` 会自动 taskkill); - **改 logo**:改 `src/logos/logo_ph_orange.svg` → `python src/logos/make_ico.py` → 重新打包; - **配置互不影响**:exe 与源码脚本各自按所在目录保存 `pattern_slots.json`,两份安装互不干扰。 diff --git a/HDMI-Tool/release/README.md b/HDMI-Tool/release/README.md index 4eced41..3afd3b1 100644 --- a/HDMI-Tool/release/README.md +++ b/HDMI-Tool/release/README.md @@ -1,11 +1,11 @@ -# HDMI-Tool — HDMI 源端调试 + 光机控制工具 +# DLPilot — HDMI 源端调试 + 光机控制工具 > LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套的 PC 端工具: > HDMI **源端**(EDID 查看、分辨率切换、纯色/测试图案投屏)+ **光机控制** > (RS485 直连板上 GD32 网关,开关光机/设光强/查温度电流)。 > > - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases) -> 下载 `HDMI-Tool-vX.Y.Z-win64.zip`,解压双击 `HDMI-Tool.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。 +> 下载 `DLPilot-vX.Y.Z-win64.zip`,解压双击 `DLPilot.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。 > - **要改代码/重新打包**:见 §8、§9。 > > 更新:2026-09-11 @@ -29,15 +29,15 @@ ## 2. 下载与安装 1. 打开 [Releases 页面](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases),取最新的 - `HDMI-Tool-vX.Y.Z-win64.zip`; + `DLPilot-vX.Y.Z-win64.zip`; 2. 解压到**任意可写目录**(自定义图片与栏位配置会写到 exe 旁边); -3. 双击 `HDMI-Tool.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。 +3. 双击 `DLPilot.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。 包内包含: ``` -HDMI-Tool/ -├── HDMI-Tool.exe 主程序(单文件自包含, 光机控制的 pyserial 已内置) +DLPilot/ +├── DLPilot.exe 主程序(单文件自包含, 光机控制的 pyserial 已内置) ├── pattern_slots.json 预置自定义栏位 + 串口记忆 ├── bw_*.png ×5 640x360 黑白测试图 ├── solid_*.png ×10 800x600 纯色测试图 @@ -72,7 +72,7 @@ HDMI-Tool/ ## 4. 典型测试场景 ``` -1. HDMI 接转接板 → 启动 HDMI-Tool → 刷新 → 选中板卡显示器 +1. HDMI 接转接板 → 启动 DLPilot → 刷新 → 选中板卡显示器 2. 板卡 shadow EDID 首选 640x360@60 → 测试卡区域选 640x360(与光机面板原生 1:1) 3. "光机控制"连接串口 → M999 查版本确认链路通 → M730S0 开光机 4. 点 R/G/B 行纯色按钮投图 → 目视/逻辑分析仪核对 RGB888 输出 @@ -180,7 +180,7 @@ python la_wave_analyzer3.py # 3 通道(RGB 三组同拍, ch8/ ## 8. 目录结构与从源码运行 ``` -HDMI-Tool/ +DLPilot/ ├── src/ # 源代码(人工维护区) │ ├── hdmi_gui.py # 入口与命令行模式(argparse 分发) │ ├── app_gui.py # CustomTkinter 主界面(双栏仪表盘) @@ -202,7 +202,7 @@ HDMI-Tool/ │ ├── bw_*.png ×5 # 640x360 黑白系列 │ └── solid_*.png ×10 # 800x600 纯色系列 ├── release/ # 构建与发布产物(机器生成区, 不手改) -│ ├── HDMI-Tool.exe # 打包单文件版(改动跟随源码提交入库) +│ ├── DLPilot.exe # 打包单文件版(改动跟随源码提交入库) │ ├── pattern_slots.json # exe 运行配置(栏位预置 + 串口记忆) │ ├── *.png / README.md # 附属文件平铺副本(由 package_release.py 从 assets/ 同步) │ └── *.zip # 发布包(不入库, 上传 Gitea Release) @@ -220,11 +220,11 @@ GUI 需 customtkinter(`py -m pip install customtkinter`);光机控制需 p ```powershell cd src python selftest_rs485.py # 光机协议回归(改 dlpc_rs485.py 后必跑) -python build_exe.py # 打包 → ../release/HDMI-Tool.exe(自动装 PyInstaller; +python build_exe.py # 打包 → ../release/DLPilot.exe(自动装 PyInstaller; # 内置 pyserial/customtkinter, CTk 主题数据自动收集) python build_exe.py --debug # 额外出一个带控制台的调试变体(--dump 可见输出) -python package_release.py 0.2.0 # 组装 release/HDMI-Tool-v0.2.0-win64.zip -python publish_gitea.py HDMI-Tool-v0.2.0-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN) +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) ``` ## 10. 注意事项 / FAQ @@ -235,6 +235,6 @@ python publish_gitea.py HDMI-Tool-v0.2.0-win64.zip --insecure # 上传 Gitea R - **exe 放只读目录会怎样**:自定义图片与 `pattern_slots.json` 写在 exe 旁边,只读目录会导致配置存不下; - **重新打包后图标没变**:Windows 图标缓存,`ie4uinit -show` 或改个名即可; - **多屏序号**:切分辨率前先确认显示器序号(GUI 下拉框与 `hdmi_source.bat -List` 输出一致); -- **残留进程**:修改源码重新打包前先结束残留的 HDMI-Tool 进程(`build_exe.py` 会自动 taskkill); +- **残留进程**:修改源码重新打包前先结束残留的 DLPilot 进程(`build_exe.py` 会自动 taskkill); - **改 logo**:改 `src/logos/logo_ph_orange.svg` → `python src/logos/make_ico.py` → 重新打包; - **配置互不影响**:exe 与源码脚本各自按所在目录保存 `pattern_slots.json`,两份安装互不干扰。 diff --git a/HDMI-Tool/src/app_gui.py b/HDMI-Tool/src/app_gui.py index 5eaccf6..e5fb9a0 100644 --- a/HDMI-Tool/src/app_gui.py +++ b/HDMI-Tool/src/app_gui.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -HDMI-Tool 主界面 — CustomTkinter 双栏仪表盘 +DLPilot 主界面 — CustomTkinter 双栏仪表盘 ================================================================ -布局(HDMI-Tool/UI参考布局.svg): +布局(DLPilot/UI参考布局.svg): 左列 显示器选择 | 支持的模式 | EDID 信息 | 测试图案/纯色/自定义 | EDID 原始数据 右列 串口选择与参数 | 串口数据(收发日志) | 预设与自定义命令 | 光强/定时快捷滑条 默认深色外观, 顶栏可切换 Dark/Light/System。 @@ -12,6 +12,7 @@ HDMI-Tool 主界面 — CustomTkinter 双栏仪表盘 投屏窗口在 patterns, 串口协议在 dlpc_rs485, 运行配置在 config。 """ +import ctypes import ctypes import os import queue @@ -63,7 +64,11 @@ def run_gui(auto_close=0): class App(ctk.CTk): def __init__(self, auto_close=0): super().__init__() - self.title('HDMI-Tool — EDID 查看器 + 测试图案 + 光机控制 (LT8619C 转接板调试)') + self.title('DLPilot — 3D 光固化光机调试台 (EDID · 测试图案 · RS485 光机控制)') + try: # 任务栏独立分组 + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID('hulk.DLPilot') + except Exception: + pass self.geometry('1440x920') self.minsize(1180, 800) ic = get_icon_path() @@ -97,8 +102,10 @@ class App(ctk.CTk): def _set_appearance(self, mode): ctk.set_appearance_mode(mode) - def _card(self, parent, title, button=None, command=None, right_label=None): - """卡片容器: 圆角边框 + 标题行(可带右侧小按钮/右对齐标签), 返回 (卡片, 内容 frame) + def _card(self, parent, title, button=None, command=None, right_label=None, + right_widget=None): + """卡片容器: 圆角边框 + 标题行(可带右侧小按钮/右对齐标签/自定义控件), + 返回 (卡片, 内容 frame) 卡片自身用 grid 参与列布局; 卡片内部(head/content/行控件)一律用 pack。 """ @@ -107,6 +114,8 @@ class App(ctk.CTk): head = ctk.CTkFrame(card, fg_color='transparent') head.pack(fill='x', padx=12, pady=(8, 0)) ctk.CTkLabel(head, text=title, font=BOLD, anchor='w').pack(side='left') + if right_widget is not None: # 自定义控件(如 电流读数+查电流) + right_widget.pack(side='right') if right_label is not None: ctk.CTkLabel(head, textvariable=right_label, text_color='gray60').pack(side='right') @@ -120,7 +129,7 @@ class App(ctk.CTk): def _build(self): top = ctk.CTkFrame(self, fg_color='transparent') top.pack(fill='x', padx=12, pady=(10, 2)) - ctk.CTkLabel(top, text='HDMI-Tool', font=TITLE_FONT).pack(side='left') + ctk.CTkLabel(top, text='DLPilot', font=TITLE_FONT).pack(side='left') ctk.CTkLabel(top, text='EDID · 测试图案 · 光机控制', text_color='gray60').pack(side='left', padx=10, pady=(6, 0)) seg = ctk.CTkSegmentedButton(top, values=['Dark', 'Light', 'System'], @@ -149,8 +158,8 @@ class App(ctk.CTk): ).pack(expand=True) def _build_left(self, col): - col.grid_rowconfigure(1, weight=2) # 支持的模式(与 EDID 信息等高) - col.grid_rowconfigure(2, weight=2) # EDID 信息 + col.grid_rowconfigure(1, weight=2) # 支持的模式 + col.grid_rowconfigure(2, weight=1) # EDID 信息(压缩高度) col.grid_rowconfigure(4, weight=0) # EDID 原始数据(固定高, 压缩) col.grid_columnconfigure(0, weight=1) @@ -218,7 +227,7 @@ class App(ctk.CTk): row3 = ctk.CTkFrame(c, fg_color='transparent') row3.pack(fill='x', pady=(6, 0)) self.slot_buttons = [] - for i in range(config.SLOT_COUNT): + for i in range(3): # 自定义栏位(沿用原 3 槽配置) cell = ctk.CTkFrame(row3, fg_color='transparent') cell.pack(side='left', padx=(0, 10)) btn = ctk.CTkButton(cell, text=self.slots[i]['name'], width=96, @@ -231,6 +240,22 @@ class App(ctk.CTk): self.slot_buttons.append(btn) ctk.CTkLabel(row3, text='点击投图, 齿轮按钮配置图片', text_color='gray50').pack(side='left', padx=4) + # -- 光校图案(预留 5 槽): 3D 光固化打印测试/校准用图 -- + row4 = ctk.CTkFrame(c, fg_color='transparent') + row4.pack(fill='x', pady=(6, 0)) + for i in range(3, config.SLOT_COUNT): + cell = ctk.CTkFrame(row4, fg_color='transparent') + cell.pack(side='left', padx=(0, 10)) + btn = ctk.CTkButton(cell, text=self.slots[i]['name'], width=96, + command=lambda i=i: self._show_custom(i)) + btn.pack(side='left') + ctk.CTkButton(cell, text='\uE713', width=30, height=24, + font=('Segoe MDL2 Assets', 13), + command=lambda i=i: self._configure_slot(i) + ).pack(side='left', padx=(4, 0)) + self.slot_buttons.append(btn) + ctk.CTkLabel(row4, text='校准图案(预留 5 槽, 齿轮改图)', + text_color='gray50').pack(side='left', padx=4) # -- EDID 原始数据 -- card, c = self._card(col, 'EDID 原始数据', @@ -293,12 +318,20 @@ class App(ctk.CTk): '累加和校验; 全部命令见 README"光机控制"') # -- 光机命令 -- - card, c = self._card(col,'光机命令 — 预设 / 自定义 / TEC') + # 标题行右侧: 电流实时读数 + 查电流按钮(okR..G..B.. 应答解析) + cur_frame = ctk.CTkFrame(self, fg_color='transparent') + self.var_current = tk.StringVar(value='电流: —') + ctk.CTkLabel(cur_frame, textvariable=self.var_current, width=168, + anchor='e', text_color='#3B8ED0').pack(side='left') + ctk.CTkButton(cur_frame, text='查电流', width=64, height=24, + 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)) row1 = ctk.CTkFrame(c, fg_color='transparent') row1.pack(fill='x') for name, cmd in (('开光机', 'M730S0'), ('关光机', 'M730S1'), - ('查电流', 'M732'), ('查版本', 'M999'), ('软件重启', 'M888'), + ('查版本', 'M999'), ('软件重启', 'M888'), ('开TEC', 'M7S0'), ('关TEC', 'M7S1'), ('TEC33.0°C', 'M8S330')): ctk.CTkButton(row1, text=name, width=64, command=lambda cc=cmd: self._dlpc_send(cc) @@ -365,7 +398,7 @@ class App(ctk.CTk): textvariable=self.var_light) self.ent_light.pack(side='left') self.ent_light.bind('', self._light_entry_apply) - ctk.CTkButton(rowa, text='下发 M731S', width=110, + ctk.CTkButton(rowa, text='下发电流值', width=110, command=self._dlpc_send_light).pack(side='left', padx=(6, 0)) rowb = ctk.CTkFrame(c, fg_color='transparent') rowb.pack(fill='x', pady=(8, 0)) @@ -395,7 +428,7 @@ class App(ctk.CTk): textvariable=self.var_tec) self.ent_tec.pack(side='left') self.ent_tec.bind('', self._tec_entry_apply) - ctk.CTkButton(rowc, text='下发 M8S', width=110, + ctk.CTkButton(rowc, text='下发温度值', width=110, command=self._dlpc_send_tec).pack(side='left', padx=(6, 0)) # ============================================================ @@ -634,8 +667,16 @@ class App(ctk.CTk): except ValueError: text = f'{which}: {m.group(1)}' self._dlpc_log_q.put(('temp', text)) + # 电流查询应答: okRGB -> 标题行实时读数 + m = re.search(r'← okR(\d+)G(\d+)B(\d+)', line) + if m: + self._dlpc_log_q.put(('current', f'电流 R:{m.group(1)} ' + f'G:{m.group(2)} B:{m.group(3)}')) self._dlpc_log_q.put(('event', line)) + def _query_current(self): + self._dlpc_send('M732') + def _query_dmd(self): self._tl_order.append('DMD') self._dlpc_send('M201') @@ -666,6 +707,8 @@ class App(ctk.CTk): self.var_dmd.set(text) else: self.var_lamp.set(text) + elif kind == 'current': + self.var_current.set(text) self._dlpc_log(text) except queue.Empty: pass diff --git a/HDMI-Tool/src/build_exe.py b/HDMI-Tool/src/build_exe.py index f9dfbef..9c5ee57 100644 --- a/HDMI-Tool/src/build_exe.py +++ b/HDMI-Tool/src/build_exe.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -把 hdmi_gui.py 打包成单文件 exe(工具名: HDMI-Tool) +把 hdmi_gui.py 打包成单文件 exe(工具名: DLPilot) ================================ 使用 PyInstaller --onefile --noconsole: Python 解释器 + 标准库 + tkinter 全部打进一个 exe, 目标机器无需安装任何依赖。 @@ -10,7 +10,7 @@ python build_exe.py 自动安装 PyInstaller(如缺)并打包 python build_exe.py --debug 额外打一个带控制台的变体(--dump 可用) -产物: release/HDMI-Tool.exe(及 --debug 时的 HDMI-Tool-console.exe) +产物: release/DLPilot.exe(及 --debug 时的 DLPilot-console.exe) 首次运行 onefile exe 会先解压到临时目录, 启动比脚本慢一两秒, 属正常现象。 """ @@ -84,10 +84,10 @@ def main(): build_console = '--debug' in sys.argv ensure_pyinstaller() # 常规版: 双击即用, 无黑框(GUI/图案/自定义栏位全部可用, --dump 除外) - build('HDMI-Tool', ['--noconsole']) + build('DLPilot', ['--noconsole']) if build_console: # 调试版: 带控制台, --dump / 异常栈可见 - build('HDMI-Tool-console', []) + build('DLPilot-console', []) if __name__ == '__main__': diff --git a/HDMI-Tool/src/config.py b/HDMI-Tool/src/config.py index f7a2d9f..aa33656 100644 --- a/HDMI-Tool/src/config.py +++ b/HDMI-Tool/src/config.py @@ -39,7 +39,7 @@ if getattr(sys, 'frozen', False): BASE_DIR = os.path.dirname(os.path.abspath(sys.executable)) else: BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -SLOT_COUNT = 3 +SLOT_COUNT = 8 SLOT_CONFIG = os.path.join(BASE_DIR, 'pattern_slots.json') IMG_EXTS = ['.png', '.gif'] + (['.jpg', '.jpeg', '.bmp', '.webp'] if HAS_PIL else []) diff --git a/HDMI-Tool/src/package_release.py b/HDMI-Tool/src/package_release.py index 90a15de..0da7568 100644 --- a/HDMI-Tool/src/package_release.py +++ b/HDMI-Tool/src/package_release.py @@ -1,22 +1,22 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -组装 HDMI-Tool 发布包: exe + 附属资产 → 一个文件夹 + 一个 zip(解压即用) +组装 DLPilot 发布包: exe + 附属资产 → 一个文件夹 + 一个 zip(解压即用) ================================================================ 先把 assets/ 的图案复制进 release/(与 exe 平铺), 使 release/ 本身就是 可直接使用的完整文件夹; 再原样压成 zip —— 两者内容始终一致。 -zip 解压后根目录为 HDMI-Tool/, 与 exe 运行目录结构一致: - HDMI-Tool.exe 主程序(先运行 build_exe.py 打包) +zip 解压后根目录为 DLPilot/, 与 exe 运行目录结构一致: + DLPilot.exe 主程序(先运行 build_exe.py 打包) pattern_slots.json 预置自定义栏位(指向前 3 张 bw 图案) bw_*.png 640x360 黑白测试图 ×5(自定义栏位用) solid_*.png 800x600 纯色测试图 ×10(RGB 位交换测试用) README.md 使用说明 用法: - python package_release.py 版本号自动取最近 git tag(HDMI-Tool-v*) + python package_release.py 版本号自动取最近 git tag(DLPilot-v*) python package_release.py 0.1.0 手动指定版本号 -产物: release/HDMI-Tool-v<版本>-win64.zip(zip 不入库, 上传到 Gitea Release) +产物: release/DLPilot-v<版本>-win64.zip(zip 不入库, 上传到 Gitea Release) """ import os @@ -35,23 +35,23 @@ PATTERN_GLOBS = ('bw_*.png', 'solid_*.png') def detect_version(): - """从最近的 HDMI-Tool-v* tag 推断版本号, 无 tag 则报错提示手动指定""" + """从最近的 DLPilot-v* tag 推断版本号, 无 tag 则报错提示手动指定""" try: out = subprocess.check_output( - ['git', 'describe', '--tags', '--abbrev=0', '--match', 'HDMI-Tool-v*'], + ['git', 'describe', '--tags', '--abbrev=0', '--match', 'DLPilot-v*'], cwd=BASE, stderr=subprocess.DEVNULL) - return out.decode().strip().removeprefix('HDMI-Tool-v') + return out.decode().strip().removeprefix('DLPilot-v') except Exception: sys.exit('未能从 git tag 推断版本号, 请手动指定: python package_release.py <版本号>') def main(): version = (sys.argv[1] if len(sys.argv) > 1 else detect_version()).lstrip('v') - exe = os.path.join(RELEASE, 'HDMI-Tool.exe') + exe = os.path.join(RELEASE, 'DLPilot.exe') slots = os.path.join(RELEASE, 'pattern_slots.json') readme = os.path.join(BASE, 'README.md') if not os.path.exists(exe): - sys.exit('缺少 release/HDMI-Tool.exe, 先运行: python build_exe.py') + sys.exit('缺少 release/DLPilot.exe, 先运行: python build_exe.py') for must in (slots, readme): if not os.path.exists(must): sys.exit(f'缺少 {must}') @@ -70,11 +70,11 @@ def main(): shutil.copy2(os.path.join(ASSETS, p), os.path.join(RELEASE, p)) shutil.copy2(readme, os.path.join(RELEASE, 'README.md')) - zip_name = f'HDMI-Tool-v{version}-win64.zip' + zip_name = f'DLPilot-v{version}-win64.zip' zip_path = os.path.join(RELEASE, zip_name) with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z: - for p in ['HDMI-Tool.exe', 'pattern_slots.json', 'README.md'] + patterns: - z.write(os.path.join(RELEASE, p), f'HDMI-Tool/{p}') + for p in ['DLPilot.exe', 'pattern_slots.json', 'README.md'] + patterns: + z.write(os.path.join(RELEASE, p), f'DLPilot/{p}') print(f'发布文件夹: {RELEASE} (exe+配置+说明+{len(patterns)} 张图案, 平铺即用)') print(f'发布包: {zip_path} ({os.path.getsize(zip_path) / 1024 / 1024:.1f} MB)') print('下一步上传: python publish_gitea.py', zip_name) diff --git a/HDMI-Tool/src/publish_gitea.py b/HDMI-Tool/src/publish_gitea.py index cd1ebaa..87f95c7 100644 --- a/HDMI-Tool/src/publish_gitea.py +++ b/HDMI-Tool/src/publish_gitea.py @@ -4,12 +4,12 @@ 创建/更新 Gitea Release 并上传发布 zip(纯标准库, 无需安装依赖) ================================================================ 用法: - python publish_gitea.py HDMI-Tool-v0.1.0-win64.zip + python publish_gitea.py DLPilot-v0.1.0-win64.zip python publish_gitea.py --notes-file release/notes-v0.1.0.md - python publish_gitea.py --tag HDMI-Tool-v0.1.0 --replace + python publish_gitea.py --tag DLPilot-v0.1.0 --replace 行为: - - tag 与版本默认取 zip 文件名(HDMI-Tool-vX.Y.Z-win64.zip → HDMI-Tool-vX.Y.Z) + - tag 与版本默认取 zip 文件名(DLPilot-vX.Y.Z-win64.zip → DLPilot-vX.Y.Z) - release 不存在则创建(tag 不存在时 Gitea 在默认分支头自动建 tag); 已存在则复用, 仅上传/更新资产 - release 上已有同名资产时: 默认跳过, --replace 删旧再传 @@ -27,16 +27,16 @@ import urllib.error import urllib.request import uuid -DEFAULT_NOTES = """## HDMI-Tool {version} +DEFAULT_NOTES = """## DLPilot {version} HDMI 源端调试工具(LT8619C → DLPC3421 转接板配套): EDID 查看/逐字节解析、 分辨率切换、纯色与测试图案全屏投屏。 ### 安装 -下载 `HDMI-Tool-v{version}-win64.zip`, 解压后双击 `HDMI-Tool.exe`(Windows 10/11 x64, 免安装)。 +下载 `DLPilot-v{version}-win64.zip`, 解压后双击 `DLPilot.exe`(Windows 10/11 x64, 免安装)。 ### 包内容 -- `HDMI-Tool.exe` — 主程序 +- `DLPilot.exe` — 主程序 - `pattern_slots.json` — 预置自定义栏位(指向 bw_*.png) - `bw_*.png` ×5 / `solid_*.png` ×10 — 测试图案 - `README.md` — 完整使用说明 @@ -119,7 +119,7 @@ def main(): sys.exit('缺少 API token: 传 --token 或设环境变量 GITEA_TOKEN') tag = args.tag or re.sub(r'-win64\.zip$', '', os.path.basename(args.zipfile_path)) - version = tag.removeprefix('HDMI-Tool-v') + version = tag.removeprefix('DLPilot-v') notes = open(args.notes_file, encoding='utf-8').read() if args.notes_file \ else DEFAULT_NOTES.format(version=version) @@ -133,7 +133,7 @@ def main(): else: status, rel = api.request('POST', '/releases', json.dumps({ 'tag_name': tag, - 'name': f'HDMI-Tool v{version}', + 'name': f'DLPilot v{version}', 'body': notes, }).encode(), {'Content-Type': 'application/json'}) if status != 201: diff --git a/README.md b/README.md index 829728b..2eccaa1 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,16 @@ LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套上位机工 | 工具 | 用途 | 获取 | |---|---|---| -| [HDMI-Tool](HDMI-Tool/) | HDMI 源端(EDID 查看/逐字节解析、分辨率切换、纯色与测试图案全屏投屏)+ 光机控制(RS485→GD32 网关→DLPC3421:开关光机/设光强/查温度电流) | 到 [Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases) 下 `HDMI-Tool-vX.Y.Z-win64.zip`,解压即用;详见 [HDMI-Tool/README.md](HDMI-Tool/README.md) | +| [DLPilot](DLPilot/) | HDMI 源端(EDID 查看/逐字节解析、分辨率切换、纯色与测试图案全屏投屏)+ 光机控制(RS485→GD32 网关→DLPC3421:开关光机/设光强/查温度电流) | 到 [Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases) 下 `DLPilot-vX.Y.Z-win64.zip`,解压即用;详见 [DLPilot/README.md](DLPilot/README.md) | | [I2C-Inject](I2C-Inject/) | LT8619C 寄存器在线读写:RP2040 注入器在 demo 板 I2C 总线上择机注入(避让主控轮询,写后自动读回校验) | 需配合 RP2040 固件 `rp2040_inject.py`;详见 [I2C-Inject/README.md](I2C-Inject/README.md) | ## 环境要求 - 发布版 zip 开箱即用(Windows 10/11 x64,无需安装 Python) -- 源码运行:Python ≥3.10;HDMI-Tool 命令行/EDID 纯标准库,GUI 需 +- 源码运行:Python ≥3.10;DLPilot 命令行/EDID 纯标准库,GUI 需 `py -m pip install customtkinter`(自定义图片 JPG/BMP 可选 Pillow、光机控制可选 pyserial); I2C-Inject 需 `py -m pip install pyserial` -- 重新打包:`HDMI-Tool/src/build_exe.py`(自动安装 PyInstaller);I2C-Inject 目录内 `python build_exe.py` +- 重新打包:`DLPilot/src/build_exe.py`(自动安装 PyInstaller);I2C-Inject 目录内 `python build_exe.py` ## 目录结构 @@ -25,7 +25,7 @@ LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套上位机工 ``` LT8619C_Debug_Tools/ -├── HDMI-Tool/ +├── DLPilot/ │ ├── src/ # 源码 + 打包/发布脚本 + 图标源 │ ├── assets/ # 生成的测试图案 PNG(gen_*.py 产物) │ ├── release/ # 打包 exe + 运行配置(入库);发布 zip(不入库) @@ -36,13 +36,13 @@ LT8619C_Debug_Tools/ └── README.md ``` -## 发布流程(HDMI-Tool) +## 发布流程(DLPilot) ```powershell -cd HDMI-Tool/src -python build_exe.py # 打包 → ../release/HDMI-Tool.exe -python package_release.py <版本> # → ../release/HDMI-Tool-v<版本>-win64.zip -python publish_gitea.py HDMI-Tool-v<版本>-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN) +cd DLPilot/src +python build_exe.py # 打包 → ../release/DLPilot.exe +python package_release.py <版本> # → ../release/DLPilot-v<版本>-win64.zip +python publish_gitea.py DLPilot-v<版本>-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN) ``` ## 备注 From 607767ce19158c3b557feb2c0b454800ec957221 Mon Sep 17 00:00:00 2001 From: hulk Date: Sat, 12 Sep 2026 03:15:07 +0800 Subject: [PATCH 08/10] =?UTF-8?q?feat(DLPilot):=20=E5=85=B3=E4=BA=8E?= =?UTF-8?q?=E6=8C=89=E9=92=AE=20+=20=E5=A4=96=E8=A7=82=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=20System=20+=20=E6=A0=A1=E5=87=86=E5=9B=BE=E6=A1=88=E8=A1=8C?= =?UTF-8?q?=E5=8E=8B=E7=BC=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HDMI-Tool/README.md | 10 ++++++---- HDMI-Tool/src/app_gui.py | 43 +++++++++++++++++++++++++++++++++------- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/HDMI-Tool/README.md b/HDMI-Tool/README.md index 3afd3b1..9ca9af8 100644 --- a/HDMI-Tool/README.md +++ b/HDMI-Tool/README.md @@ -46,7 +46,8 @@ DLPilot/ ## 3. GUI 使用 -主窗口为双栏仪表盘(布局设计稿:`UI参考布局.svg`),默认深色外观,顶栏可切换 Dark/Light/System: +主窗口为双栏仪表盘(布局设计稿:`UI参考布局.svg`),默认跟随系统深/浅色外观 +(顶栏可切换 Dark/Light/System),顶栏右侧"关于"按钮内含软件信息: ``` 左列(HDMI 源端) 右列(光机控制 RS485) @@ -61,12 +62,13 @@ DLPilot/ 2. **切分辨率**:"支持的模式"卡片里选模式 → "应用";测完点"恢复"; 3. **投测试图**:"测试图案"卡片先选**测试卡区域**(640x360 / 800x600 / full)→ 点图案按钮全屏投放,按 **ESC** 或点击退出;R/G/B 纯色按钮(55/AA/FF,按通道着色) - 用于 RGB 位交换类测试;自定义图片点齿轮按钮配置(图片自动复制到 exe 旁); + 用于 RGB 位交换类测试;自定义图片点齿轮按钮配置(图片自动复制到 exe 旁), + "校准图案"一排为 3D 光固化测试/校准图预留槽位; 4. **光机控制**:右列选串口与参数(波特率/数据位/校验/停止位,默认 115200 8-N-1)→ 连接 → 快捷按钮/预设下拉/自定义输入(回车即发);"查DMD温度""查LAMP温度"读温度 - (应答实时填入按钮旁的显示框);快捷滑条:光强拖动后"下发 M731S", + (应答实时填入按钮旁的显示框);快捷滑条:光强拖动后"下发电流值", 定时毫秒后"开光机(定时)"(0=不限时 M730S0),TEC 凝露点滑条/输入框 - (200-350 即 20.0-35.0°C)后"下发 M8S";数值框输入后回车自动限幅同步; + (200-350 即 20.0-35.0°C)后"下发温度值";数值框输入后回车自动限幅同步; 5. 测试卡区域、自定义栏位与串口记忆(含参数)自动保存在 exe 旁的 `pattern_slots.json`。 ## 4. 典型测试场景 diff --git a/HDMI-Tool/src/app_gui.py b/HDMI-Tool/src/app_gui.py index e5fb9a0..6cc5c5a 100644 --- a/HDMI-Tool/src/app_gui.py +++ b/HDMI-Tool/src/app_gui.py @@ -33,6 +33,10 @@ MONO = ('Consolas', 12) BOLD = ('Microsoft YaHei UI', 13, 'bold') TITLE_FONT = ('Microsoft YaHei UI', 18, 'bold') +APP_NAME = 'DLPilot' +APP_VERSION = '0.3.0' +REPO_URL = 'https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools' + def get_icon_path(): """工具图标: 源码模式取 logos/ 下, 打包模式取 exe 内嵌资源(_MEIPASS)""" @@ -56,7 +60,7 @@ def _apply_dropdown_theme(): def run_gui(auto_close=0): """入口: 配置外观并启动主窗口""" - ctk.set_appearance_mode('Dark') + ctk.set_appearance_mode('System') _apply_dropdown_theme() App(auto_close).mainloop() @@ -134,8 +138,13 @@ class App(ctk.CTk): text_color='gray60').pack(side='left', padx=10, pady=(6, 0)) seg = ctk.CTkSegmentedButton(top, values=['Dark', 'Light', 'System'], command=self._set_appearance) - seg.set('Dark') + seg.set('System') seg.pack(side='right') + ctk.CTkButton(top, text='关于', width=48, height=24, + fg_color='transparent', border_width=1, + border_color=('gray55', 'gray35'), + text_color='gray60', hover_color=('gray82', 'gray22'), + command=self._show_about).pack(side='right', padx=(0, 12)) body = ctk.CTkFrame(self, fg_color='transparent') body.pack(fill='both', expand=True, padx=12, pady=(4, 12)) @@ -245,16 +254,16 @@ class App(ctk.CTk): row4.pack(fill='x', pady=(6, 0)) for i in range(3, config.SLOT_COUNT): cell = ctk.CTkFrame(row4, fg_color='transparent') - cell.pack(side='left', padx=(0, 10)) - btn = ctk.CTkButton(cell, text=self.slots[i]['name'], width=96, + cell.pack(side='left', padx=(0, 6)) + btn = ctk.CTkButton(cell, text=self.slots[i]['name'], width=84, command=lambda i=i: self._show_custom(i)) btn.pack(side='left') - ctk.CTkButton(cell, text='\uE713', width=30, height=24, + ctk.CTkButton(cell, text='\uE713', width=26, height=24, font=('Segoe MDL2 Assets', 13), command=lambda i=i: self._configure_slot(i) - ).pack(side='left', padx=(4, 0)) + ).pack(side='left', padx=(3, 0)) self.slot_buttons.append(btn) - ctk.CTkLabel(row4, text='校准图案(预留 5 槽, 齿轮改图)', + ctk.CTkLabel(row4, text='校准图案(齿轮改图)', text_color='gray50').pack(side='left', padx=4) # -- EDID 原始数据 -- @@ -623,6 +632,26 @@ class App(ctk.CTk): ctk.CTkButton(btns, text='取消', width=70, fg_color='gray40', hover_color='gray25', command=dlg.destroy).pack(side='left') + def _show_about(self): + dlg = ctk.CTkToplevel(self) + dlg.title(f'关于 {APP_NAME}') + dlg.resizable(False, False) + dlg.after(150, dlg.grab_set()) + box = ctk.CTkFrame(dlg, fg_color='transparent') + box.pack(padx=26, pady=20) + ctk.CTkLabel(box, text=APP_NAME, font=TITLE_FONT, anchor='w').pack(anchor='w') + ctk.CTkLabel(box, text=f'v{APP_VERSION} — 3D 光固化光机调试台', + text_color='gray60', anchor='w').pack(anchor='w', pady=(0, 12)) + for line in ('EDID 查看 / 逐字节解析 / 分辨率切换', + '测试图案与校准图全屏投屏', + 'RS485 光机控制: 开关光机 / 光强 / 定时 / 温度 / TEC', + '配套硬件: LT8619C → DLPC3421 转接板 (GD32E230 网关)'): + ctk.CTkLabel(box, text='· ' + line, anchor='w').pack(anchor='w', pady=1) + ctk.CTkLabel(box, text='仓库: ' + REPO_URL, anchor='w', + text_color='#3B8ED0').pack(anchor='w', pady=(12, 0)) + ctk.CTkButton(box, text='关闭', width=84, + command=dlg.destroy).pack(anchor='e', pady=(14, 0)) + def _show_edid_fields(self): d = self._cur_display() if d is None: From 13dab778c65534aec5804ca64d09be7599289155 Mon Sep 17 00:00:00 2001 From: hulk Date: Sat, 12 Sep 2026 03:21:20 +0800 Subject: [PATCH 09/10] =?UTF-8?q?style(DLPilot):=20=E5=85=B3=E4=BA=8E?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E8=A1=A5=E4=BD=9C=E8=80=85/=E7=A1=AC?= =?UTF-8?q?=E4=BB=B6=E6=AD=A3=E5=BC=8F=E5=91=BD=E5=90=8D;=20=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=A0=87=E9=A2=98=E8=A1=8C=E5=9E=82=E7=9B=B4=E5=B1=85?= =?UTF-8?q?=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HDMI-Tool/release/README.md | 10 ++++++---- HDMI-Tool/src/app_gui.py | 13 +++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/HDMI-Tool/release/README.md b/HDMI-Tool/release/README.md index 3afd3b1..9ca9af8 100644 --- a/HDMI-Tool/release/README.md +++ b/HDMI-Tool/release/README.md @@ -46,7 +46,8 @@ DLPilot/ ## 3. GUI 使用 -主窗口为双栏仪表盘(布局设计稿:`UI参考布局.svg`),默认深色外观,顶栏可切换 Dark/Light/System: +主窗口为双栏仪表盘(布局设计稿:`UI参考布局.svg`),默认跟随系统深/浅色外观 +(顶栏可切换 Dark/Light/System),顶栏右侧"关于"按钮内含软件信息: ``` 左列(HDMI 源端) 右列(光机控制 RS485) @@ -61,12 +62,13 @@ DLPilot/ 2. **切分辨率**:"支持的模式"卡片里选模式 → "应用";测完点"恢复"; 3. **投测试图**:"测试图案"卡片先选**测试卡区域**(640x360 / 800x600 / full)→ 点图案按钮全屏投放,按 **ESC** 或点击退出;R/G/B 纯色按钮(55/AA/FF,按通道着色) - 用于 RGB 位交换类测试;自定义图片点齿轮按钮配置(图片自动复制到 exe 旁); + 用于 RGB 位交换类测试;自定义图片点齿轮按钮配置(图片自动复制到 exe 旁), + "校准图案"一排为 3D 光固化测试/校准图预留槽位; 4. **光机控制**:右列选串口与参数(波特率/数据位/校验/停止位,默认 115200 8-N-1)→ 连接 → 快捷按钮/预设下拉/自定义输入(回车即发);"查DMD温度""查LAMP温度"读温度 - (应答实时填入按钮旁的显示框);快捷滑条:光强拖动后"下发 M731S", + (应答实时填入按钮旁的显示框);快捷滑条:光强拖动后"下发电流值", 定时毫秒后"开光机(定时)"(0=不限时 M730S0),TEC 凝露点滑条/输入框 - (200-350 即 20.0-35.0°C)后"下发 M8S";数值框输入后回车自动限幅同步; + (200-350 即 20.0-35.0°C)后"下发温度值";数值框输入后回车自动限幅同步; 5. 测试卡区域、自定义栏位与串口记忆(含参数)自动保存在 exe 旁的 `pattern_slots.json`。 ## 4. 典型测试场景 diff --git a/HDMI-Tool/src/app_gui.py b/HDMI-Tool/src/app_gui.py index 6cc5c5a..837bcfc 100644 --- a/HDMI-Tool/src/app_gui.py +++ b/HDMI-Tool/src/app_gui.py @@ -117,15 +117,15 @@ class App(ctk.CTk): border_color=('gray70', 'gray30')) head = ctk.CTkFrame(card, fg_color='transparent') head.pack(fill='x', padx=12, pady=(8, 0)) - ctk.CTkLabel(head, text=title, font=BOLD, anchor='w').pack(side='left') + ctk.CTkLabel(head, text=title, font=BOLD, anchor='w').pack(side='left', fill='y') if right_widget is not None: # 自定义控件(如 电流读数+查电流) - right_widget.pack(side='right') + right_widget.pack(side='right', fill='y') if right_label is not None: ctk.CTkLabel(head, textvariable=right_label, - text_color='gray60').pack(side='right') + text_color='gray60').pack(side='right', fill='y') if button: - ctk.CTkButton(head, text=button, width=92, height=24, - command=command).pack(side='right') + ctk.CTkButton(head, text=button, width=92, + command=command).pack(side='right', fill='y') content = ctk.CTkFrame(card, fg_color='transparent') content.pack(fill='both', expand=True, padx=12, pady=(2, 10)) return card, content @@ -645,7 +645,8 @@ class App(ctk.CTk): for line in ('EDID 查看 / 逐字节解析 / 分辨率切换', '测试图案与校准图全屏投屏', 'RS485 光机控制: 开关光机 / 光强 / 定时 / 温度 / TEC', - '配套硬件: LT8619C → DLPC3421 转接板 (GD32E230 网关)'): + '配套硬件: DLPilot(HDMI-0.16" DLP 转接板)', + '作者: Hulk'): ctk.CTkLabel(box, text='· ' + line, anchor='w').pack(anchor='w', pady=1) ctk.CTkLabel(box, text='仓库: ' + REPO_URL, anchor='w', text_color='#3B8ED0').pack(anchor='w', pady=(12, 0)) From b12bc584b8096917348aea9cb96021dd1ce89a60 Mon Sep 17 00:00:00 2001 From: hulk Date: Sat, 12 Sep 2026 03:28:52 +0800 Subject: [PATCH 10/10] =?UTF-8?q?feat(DLPilot):=20=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=B8=BA=20DLP/Pilot=20=E5=8F=8C=E8=A1=8C?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1;=20=E5=9B=BE=E6=A1=88=E6=A0=8F=E4=BD=8D?= =?UTF-8?q?=E4=B8=A4=E6=8E=92=E5=90=84=205=20=E4=B8=AA=E7=AD=89=E5=AE=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HDMI-Tool/src/app_gui.py | 57 ++++++++++--------------- HDMI-Tool/src/build_exe.py | 4 +- HDMI-Tool/src/config.py | 2 +- HDMI-Tool/src/logos/logo_dlpilot.ico | Bin 0 -> 21109 bytes HDMI-Tool/src/logos/logo_dlpilot.png | Bin 0 -> 9131 bytes HDMI-Tool/src/logos/logo_ph_orange.ico | Bin 22047 -> 0 bytes HDMI-Tool/src/logos/logo_ph_orange.png | Bin 7522 -> 0 bytes HDMI-Tool/src/logos/logo_ph_orange.svg | 6 --- HDMI-Tool/src/logos/make_ico.py | 22 +++++----- 9 files changed, 37 insertions(+), 54 deletions(-) create mode 100644 HDMI-Tool/src/logos/logo_dlpilot.ico create mode 100644 HDMI-Tool/src/logos/logo_dlpilot.png delete mode 100644 HDMI-Tool/src/logos/logo_ph_orange.ico delete mode 100644 HDMI-Tool/src/logos/logo_ph_orange.png delete mode 100644 HDMI-Tool/src/logos/logo_ph_orange.svg diff --git a/HDMI-Tool/src/app_gui.py b/HDMI-Tool/src/app_gui.py index 837bcfc..3e2a984 100644 --- a/HDMI-Tool/src/app_gui.py +++ b/HDMI-Tool/src/app_gui.py @@ -40,13 +40,12 @@ REPO_URL = 'https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools' def get_icon_path(): """工具图标: 源码模式取 logos/ 下, 打包模式取 exe 内嵌资源(_MEIPASS)""" - import os import sys if getattr(sys, 'frozen', False): - p = os.path.join(getattr(sys, '_MEIPASS', ''), 'logo_ph_orange.ico') + p = os.path.join(getattr(sys, '_MEIPASS', ''), 'logo_dlpilot.ico') else: p = os.path.join(os.path.dirname(os.path.abspath(__file__)), - 'logos', 'logo_ph_orange.ico') + 'logos', 'logo_dlpilot.ico') return p if os.path.exists(p) else None @@ -233,38 +232,28 @@ class App(ctk.CTk): text_color='#000000' if color == '#00FF00' else '#FFFFFF', command=lambda cc=color: self._show_solid(cc) ).pack(side='left', padx=3) - row3 = ctk.CTkFrame(c, fg_color='transparent') - row3.pack(fill='x', pady=(6, 0)) + # -- 图案栏位: 两排各 5 个, 等宽 -- self.slot_buttons = [] - for i in range(3): # 自定义栏位(沿用原 3 槽配置) - cell = ctk.CTkFrame(row3, fg_color='transparent') - cell.pack(side='left', padx=(0, 10)) - btn = ctk.CTkButton(cell, text=self.slots[i]['name'], width=96, - command=lambda i=i: self._show_custom(i)) - btn.pack(side='left') - ctk.CTkButton(cell, text='\uE713', width=30, height=24, - font=('Segoe MDL2 Assets', 13), - command=lambda i=i: self._configure_slot(i) - ).pack(side='left', padx=(4, 0)) - self.slot_buttons.append(btn) - ctk.CTkLabel(row3, text='点击投图, 齿轮按钮配置图片', - text_color='gray50').pack(side='left', padx=4) - # -- 光校图案(预留 5 槽): 3D 光固化打印测试/校准用图 -- - row4 = ctk.CTkFrame(c, fg_color='transparent') - row4.pack(fill='x', pady=(6, 0)) - for i in range(3, config.SLOT_COUNT): - cell = ctk.CTkFrame(row4, fg_color='transparent') - cell.pack(side='left', padx=(0, 6)) - btn = ctk.CTkButton(cell, text=self.slots[i]['name'], width=84, - command=lambda i=i: self._show_custom(i)) - btn.pack(side='left') - ctk.CTkButton(cell, text='\uE713', width=26, height=24, - font=('Segoe MDL2 Assets', 13), - command=lambda i=i: self._configure_slot(i) - ).pack(side='left', padx=(3, 0)) - self.slot_buttons.append(btn) - ctk.CTkLabel(row4, text='校准图案(齿轮改图)', - text_color='gray50').pack(side='left', padx=4) + for row_idx, group in enumerate((range(0, 5), range(5, 10))): + row = ctk.CTkFrame(c, fg_color='transparent') + row.pack(fill='x', pady=(6, 0)) + for i in group: + cell = ctk.CTkFrame(row, fg_color='transparent') + cell.pack(side='left', padx=(0, 6)) + btn = ctk.CTkButton(cell, text=self.slots[i]['name'], width=84, + command=lambda i=i: self._show_custom(i)) + btn.pack(side='left') + ctk.CTkButton(cell, text='\uE713', width=26, height=24, + font=('Segoe MDL2 Assets', 13), + command=lambda i=i: self._configure_slot(i) + ).pack(side='left', padx=(3, 0)) + self.slot_buttons.append(btn) + if row_idx == 0: + ctk.CTkLabel(row, text='点击投图, 齿轮按钮配置图片', + text_color='gray50').pack(side='left', padx=4) + else: + ctk.CTkLabel(row, text='校准图案(齿轮改图)', + text_color='gray50').pack(side='left', padx=4) # -- EDID 原始数据 -- card, c = self._card(col, 'EDID 原始数据', diff --git a/HDMI-Tool/src/build_exe.py b/HDMI-Tool/src/build_exe.py index 9c5ee57..d3d9ddb 100644 --- a/HDMI-Tool/src/build_exe.py +++ b/HDMI-Tool/src/build_exe.py @@ -21,7 +21,7 @@ import sys # 本脚本位于 src/, 打包产物输出到工具根的 release/(与 pattern_slots.json 同目录) SRC = os.path.dirname(os.path.abspath(__file__)) BASE = os.path.dirname(SRC) -ICO = os.path.join(SRC, 'logos', 'logo_ph_orange.ico') +ICO = os.path.join(SRC, 'logos', 'logo_dlpilot.ico') def ensure_pyinstaller(): @@ -73,7 +73,7 @@ def build(name, extra_args): # exe 文件图标 + 运行时窗口图标资源(从 _MEIPASS 读取) cmd += ['--icon', ICO, '--add-data', f'{ICO};.'] else: - print('警告: 未找到 logos/logo_ph_orange.ico(先运行 logos/make_ico.py), 本次打包不带图标') + print('警告: 未找到 logos/logo_dlpilot.ico(先运行 logos/make_ico.py), 本次打包不带图标') print('\n>>', ' '.join(cmd)) subprocess.check_call(cmd) out = os.path.join(dist, name + '.exe') diff --git a/HDMI-Tool/src/config.py b/HDMI-Tool/src/config.py index aa33656..e4aaf65 100644 --- a/HDMI-Tool/src/config.py +++ b/HDMI-Tool/src/config.py @@ -39,7 +39,7 @@ if getattr(sys, 'frozen', False): BASE_DIR = os.path.dirname(os.path.abspath(sys.executable)) else: BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -SLOT_COUNT = 8 +SLOT_COUNT = 10 SLOT_CONFIG = os.path.join(BASE_DIR, 'pattern_slots.json') IMG_EXTS = ['.png', '.gif'] + (['.jpg', '.jpeg', '.bmp', '.webp'] if HAS_PIL else []) diff --git a/HDMI-Tool/src/logos/logo_dlpilot.ico b/HDMI-Tool/src/logos/logo_dlpilot.ico new file mode 100644 index 0000000000000000000000000000000000000000..924d85cce396e7c49dd1b1f1185d038de08af6f3 GIT binary patch literal 21109 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 diff --git a/HDMI-Tool/src/logos/logo_ph_orange.svg b/HDMI-Tool/src/logos/logo_ph_orange.svg deleted file mode 100644 index af4af1f..0000000 --- a/HDMI-Tool/src/logos/logo_ph_orange.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - HDMI - Tool - diff --git a/HDMI-Tool/src/logos/make_ico.py b/HDMI-Tool/src/logos/make_ico.py index 6536889..9a950e9 100644 --- a/HDMI-Tool/src/logos/make_ico.py +++ b/HDMI-Tool/src/logos/make_ico.py @@ -1,13 +1,13 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -把 logo_ph_orange.svg 同款设计栅格化为多尺寸 .ico(供 exe 图标) +DLPilot 图标栅格化: 黑底 512, 橙色圆角矩形, 第一行 DLP(框内黑字), 第二行 Pilot(白字) ================================================================ -设计参数与 SVG 严格一致: 黑底 512, 橙色圆角矩形 x38 y64 w436 h186 rx34, -HDMI 146px Arial Bold 黑字(框内居中), Tool 146px 白字(基线 421)。 +设计参数: 黑底 512, 橙色圆角矩形 x38 y64 w436 h186 rx34, +DLP 146px Arial Bold 黑字(框内居中), Pilot 146px Arial Bold 白字(基线 421)。 -输出: logos/logo_ph_orange.png(512, 预览/复用) - logos/logo_ph_orange.ico(16~256 七档, exe 图标用) +输出: logos/logo_dlpilot.png(512, 预览/复用) + logos/logo_dlpilot.ico(16~256 七档, exe 图标用) 用法: python make_ico.py """ @@ -30,17 +30,17 @@ def render(size=512): raise SystemExit(f'未找到字体 {FONT}, 无法栅格化') f = ImageFont.truetype(FONT, max(8, round(146 * s))) cx = size / 2 - # HDMI: 居中于橙色框 - d.text((cx, 64 * s + 186 * s / 2), 'HDMI', font=f, fill=(0, 0, 0), anchor='mm') - # Tool: 与 SVG 基线 421 对齐(无下伸字母, 中心 = 421 - cap/2 ~= 368) - d.text((cx, 368 * s), 'Tool', font=f, fill=(255, 255, 255), anchor='mm') + # DLP: 居中于橙色框 + d.text((cx, 64 * s + 186 * s / 2), 'DLP', font=f, fill=(0, 0, 0), anchor='mm') + # Pilot: 与原设计基线 421 对齐(无下伸字母, 中心 = 421 - cap/2 ~= 368) + d.text((cx, 368 * s), 'Pilot', font=f, fill=(255, 255, 255), anchor='mm') return img if __name__ == '__main__': img = render(512) - png = os.path.join(BASE, 'logo_ph_orange.png') - ico = os.path.join(BASE, 'logo_ph_orange.ico') + png = os.path.join(BASE, 'logo_dlpilot.png') + ico = os.path.join(BASE, 'logo_dlpilot.ico') img.save(png) img.save(ico, sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)])