1444 lines
65 KiB
Python
1444 lines
65 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 --edid-fields 控制台输出 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
|
||
|
||
|
||
def ensure_display_extend():
|
||
"""把 Windows 显示拓扑强制为"扩展"。
|
||
|
||
LT8619C 转接板热插拔后, 若 Windows 停留在"仅电脑屏幕"拓扑(会记住上次
|
||
Win+P 的选择), 板卡的 EDID 虽已安装、GPU 也在按首选时序输出, 但桌面
|
||
并未扩展过去, get_displays() 就枚举不到这块"显示器"。
|
||
|
||
返回 SetDisplayConfig 的返回码(0=成功); 任何异常静默, 不影响工具启动。
|
||
"""
|
||
try:
|
||
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('<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] & 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('<H', raw, 10)[0]:04X} ({struct.unpack_from('<H', raw, 10)[0]})",
|
||
'厂商自定')
|
||
add(12, 4, '序列号(小端)', f"0x{struct.unpack_from('<I', raw, 12)[0]:08X}",
|
||
'厂商自定')
|
||
add(16, 1, '生产周', f"第 {raw[16]} 周", '0 = 不指定')
|
||
add(17, 1, '生产年', f"{1990 + raw[17]} 年", '字节值 + 1990')
|
||
add(18, 2, 'EDID 版本号', f"{raw[18]}.{raw[19]}", '常用 1.4 / 1.3(即字节 01 04 / 01 03)')
|
||
|
||
# 字节 0x14 输入类型
|
||
if raw[20] & 0x80:
|
||
itf, bpc = INTERFACES[raw[20] & 0x7], DEPTHS[(raw[20] >> 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': '网格线'}
|
||
# 纯色测试值(位交换测试用, 与 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)
|
||
# 纯色测试值: 保持"通道标签 + 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('<<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))
|
||
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)
|
||
|
||
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()
|
||
|
||
|
||
# ============================================================
|
||
# 入口
|
||
# ============================================================
|
||
def run_dump(edid_fields=False):
|
||
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
|
||
if edid_fields:
|
||
print(edid_field_text(d['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('--edid-fields', action='store_true',
|
||
help='控制台输出 EDID 逐字节字段解析表(配合 --dump 风格, 不开 GUI); exe 版不支持')
|
||
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()
|
||
|
||
# LT8619C 板卡热插拔后可能停留在"仅电脑屏幕"拓扑, 先强制扩展,
|
||
# 否则 get_displays() 枚举不到板卡的"显示器"(失败静默)
|
||
ensure_display_extend()
|
||
|
||
if args.dump or args.edid_fields:
|
||
if getattr(sys, 'frozen', False):
|
||
# --noconsole 打包的 exe 没有控制台可写, 引导用户用源码方式
|
||
import tkinter as tk
|
||
from tkinter import messagebox
|
||
r = tk.Tk()
|
||
r.withdraw()
|
||
messagebox.showinfo('不支持', 'exe 版本无控制台输出。\n请用源码方式运行:\n'
|
||
'python hdmi_gui.py --edid-fields')
|
||
sys.exit(1)
|
||
sys.exit(run_dump(edid_fields=args.edid_fields))
|
||
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()
|