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 0000000..b28ded2 Binary files /dev/null and b/HDMI-Tool/UI布局参考图.png differ 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`