1127 lines
48 KiB
Python
1127 lines
48 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
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 退出。
|
|
|
|
仅用标准库(ctypes / winreg / tkinter), 无需 pip 安装任何依赖。
|
|
|
|
命令行模式(自动化测试用):
|
|
python hdmi_gui.py 启动 GUI
|
|
python hdmi_gui.py --dump 控制台输出 EDID 解算结果
|
|
python hdmi_gui.py --test-pattern Checker --seconds 3
|
|
直接出图案, 3 秒自动退出
|
|
python hdmi_gui.py --test-pattern Blue --area 800x600
|
|
指定测试卡区域(full=铺满所选屏)
|
|
python hdmi_gui.py --seconds 5 GUI 5 秒自动关闭(冒烟测试)
|
|
"""
|
|
|
|
import argparse
|
|
import ctypes
|
|
import ctypes.wintypes as wt
|
|
import json
|
|
import math
|
|
import os
|
|
import shutil
|
|
import struct
|
|
import sys
|
|
import winreg
|
|
|
|
# 进程级 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 [])
|
|
|
|
|
|
def load_slots():
|
|
"""读 pattern_slots.json, 返回 (slots, area)。
|
|
|
|
新格式为 {'area': 'WxH'|'full', 'slots': [...]}; 兼容旧版纯数组格式(无 area)。
|
|
"""
|
|
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 []
|
|
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}, 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\\<pnp>\\<inst>' → 注册表 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
|
|
|
|
|
|
# ============================================================
|
|
# 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('<H', raw, o)[0] * 10 # 10kHz 单位
|
|
t['hact'] = raw[o + 2] | ((raw[o + 4] & 0xF0) << 4)
|
|
t['hblank'] = raw[o + 3] | ((raw[o + 4] & 0x0F) << 8)
|
|
t['vact'] = raw[o + 5] | ((raw[o + 7] & 0xF0) << 4)
|
|
t['vblank'] = raw[o + 6] | ((raw[o + 7] & 0x0F) << 8)
|
|
t['hfront'] = raw[o + 8] | ((raw[o + 11] & 0xC0) << 2)
|
|
t['hsync'] = raw[o + 9] | ((raw[o + 11] & 0x30) << 4)
|
|
t['vfront'] = ((raw[o + 10] & 0xF0) >> 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('<H', raw, 10)[0]
|
|
e['serial_num'] = struct.unpack_from('<I', raw, 12)[0]
|
|
e['week'] = raw[16]
|
|
e['year'] = 1990 + raw[17]
|
|
e['ver'] = f'{raw[18]}.{raw[19]}'
|
|
|
|
if raw[20] & 0x80:
|
|
bpc = DEPTHS[(raw[20] >> 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] & 0x08: feats.append('sRGB 特性')
|
|
if raw[24] & 0x04: 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]
|
|
hx = ' '.join(f'{v:02X}' for v in chunk)
|
|
asc = ''.join(chr(v) if 32 <= v < 127 else '.' for v in chunk)
|
|
out.append(f'{off:04X}: {hx:<48} {asc}')
|
|
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': '网格线'}
|
|
# 纯色测试值(位交换测试用, 与 tools/dist/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('<Escape>', 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('<Configure>', 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('<Configure>', 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('960x880')
|
|
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
|
|
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('<<ComboboxSelected>>', 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)
|
|
# 纯色测试值三行(R/G/B 按通道分组, 位交换测试用)
|
|
for ch, items in SOLID_ROWS:
|
|
rowf = ttk.Frame(grp)
|
|
rowf.pack(fill='x', pady=(3, 0))
|
|
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('<<ComboboxSelected>>', 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)
|
|
|
|
# --- 底部: EDID hex dump ---
|
|
bot = ttk.LabelFrame(self, text='EDID 原始数据', padding=4)
|
|
bot.pack(fill='both', expand=False, padx=8, pady=(6, 8))
|
|
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)
|
|
|
|
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()
|
|
|
|
|
|
# ============================================================
|
|
# 入口
|
|
# ============================================================
|
|
def run_dump():
|
|
displays = get_displays()
|
|
if not displays:
|
|
print('未找到活动显示器')
|
|
return 1
|
|
for i, d in enumerate(displays, 1):
|
|
print()
|
|
print(f'== 显示器 {i} {d["device"]} 显卡[{d["adapter"]}] '
|
|
f'监视器[{d["mon_name"]}] 当前输出 {d["cur_mode"]}')
|
|
if not d['edid']:
|
|
print(' EDID 读取失败(注册表无 EDID 数据)')
|
|
continue
|
|
e = parse_edid(d['edid'])
|
|
for k, v in edid_info_lines(e):
|
|
print(f' {k:<14}: {v}')
|
|
print(' ---- 支持的模式 ----')
|
|
for k, t in enumerate(e['dtds']):
|
|
src = 'DTD1(首选)' if k == 0 else f'DTD{k + 1}'
|
|
print(f" [{src}] {t['hact']}x{t['vact']}@{t['refresh']} "
|
|
f"{t['clk_khz'] / 1000:.3f}MHz 总{t['htotal']}x{t['vtotal']}")
|
|
for m in e['cea_video']:
|
|
print(f' [CEA-VIC] {m}')
|
|
for m in e['cea_blocks']:
|
|
print(f' [CEA-块] {m}')
|
|
for m in e['est']:
|
|
print(f' [已建立] {m}')
|
|
for m in e['std']:
|
|
print(f' [标准] {m}')
|
|
print(' ---- EDID 原始数据 ----')
|
|
print(hex_dump(d['edid']))
|
|
return 0
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description='HDMI EDID 查看器 + 测试图案')
|
|
ap.add_argument('--dump', action='store_true', help='控制台输出 EDID 解算结果, 不开 GUI')
|
|
ap.add_argument('--test-pattern',
|
|
choices=list(PATTERN_NAMES) + [f'Custom{i + 1}' for i in range(SLOT_COUNT)],
|
|
default=None, help='直接全屏出测试图案(不开主窗口; CustomN 为自定义栏位)')
|
|
ap.add_argument('--area', choices=list(AREA_OPTIONS), default=DEFAULT_AREA,
|
|
help='测试卡区域: WxH 黑底居中 或 full 铺满所选显示器(默认 640x360)')
|
|
ap.add_argument('--monitor', type=int, default=1, help='--test-pattern 用的显示器序号(默认 1)')
|
|
ap.add_argument('--seconds', type=int, default=0, help='自动退出秒数(GUI/图案冒烟测试)')
|
|
args = ap.parse_args()
|
|
|
|
if args.dump:
|
|
if getattr(sys, 'frozen', False):
|
|
# --noconsole 打包的 exe 没有控制台可写, 引导用户用源码方式
|
|
import tkinter as tk
|
|
from tkinter import messagebox
|
|
r = tk.Tk()
|
|
r.withdraw()
|
|
messagebox.showinfo('不支持', 'exe 版本无控制台输出。\n请用源码方式运行:\npython hdmi_gui.py --dump')
|
|
sys.exit(1)
|
|
sys.exit(run_dump())
|
|
if args.test_pattern:
|
|
displays = get_displays()
|
|
if not displays:
|
|
print('未找到活动显示器')
|
|
sys.exit(1)
|
|
d = displays[min(args.monitor, len(displays)) - 1]
|
|
if not d['rect']:
|
|
print('无法获取显示器矩形')
|
|
sys.exit(1)
|
|
import tkinter as tk
|
|
root = tk.Tk()
|
|
root.withdraw()
|
|
if args.test_pattern.startswith('Custom'):
|
|
slots, _ = load_slots()
|
|
slot = slots[int(args.test_pattern[6:]) - 1]
|
|
if not slot['file']:
|
|
print(f"错误: {args.test_pattern}({slot['name']})未配置图片")
|
|
sys.exit(1)
|
|
show_pattern_image(root, d['rect'], os.path.join(BASE_DIR, slot['file']),
|
|
args.seconds, area=args.area)
|
|
else:
|
|
show_pattern(root, d['rect'], args.test_pattern, args.seconds, area=args.area)
|
|
sys.exit(0)
|
|
run_gui(args.seconds)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|