From 7051aa1774367a2fc18edfb3f606508231674e86 Mon Sep 17 00:00:00 2001 From: hulk Date: Sat, 12 Sep 2026 01:30:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(HDMI-Tool):=20CustomTkinter=20=E5=8F=8C?= =?UTF-8?q?=E6=A0=8F=E4=BB=AA=E8=A1=A8=E7=9B=98=E7=95=8C=E9=9D=A2=E9=87=8D?= =?UTF-8?q?=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`