feat(EDID-Viewer): v0.1.0 入库 — Fluent EDID 查看器,目录对齐 DLPilot
- src/ 人写代码:main.py(Fluent 界面、Light/Dark/dark1/dark2/跟随系统主题、 关于按钮;主题三件套 = setTheme + QPalette/应用级 QSS + DWM 标题栏, 修复 Light 下窗口背景/页签/标题栏仍为系统深色的问题)、edid.py (EDID/CTA/HDMI/HDR 解析)、windows_edid.py(WMI 只读)、theme.py、 test_edid.py(9 项解析测试,含随机畸形数据无崩溃检查) - assets/ 图标与生成脚本;release/ 交付:EDID-Viewer.exe(onefile 63.7MB, 启动/图标/主题已验证)+ README,与 DLPilot/release 同约定 exe 直接入库 - 流水线对齐 DLPilot:src/build_exe.py(spec 由脚本生成不入库,剔除 PATH 上 Poppler/Conda 版本化 ICU,避免 QtCore 报 WinError 127)→ package_release.py(组装 EDID-Viewer-v*-win64.zip,zip 只上传 Gitea)→ publish_gitea.py(token 走 GITEA_TOKEN,自签证书 --insecure) - agent.md 更名 AGENTS.md(DLPilot 双文档约定:README 给人 / AGENTS.md 给 Agent);根 .gitignore 增加 EDID-viewer/release/*.zip - validation/ 保留 EDID 本机/合成样本与窗口截图、关于对话框自检脚本
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
"""Bounds-checked EDID 1.x / CTA parser. Unknown fields retain their bytes."""
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
VICS = {1:'640×480p 60 Hz',2:'720×480p 60 Hz 4:3',3:'720×480p 60 Hz 16:9',
|
||||
4:'1280×720p 60 Hz',5:'1920×1080i 60 Hz',16:'1920×1080p 60 Hz',
|
||||
17:'720×576p 50 Hz 4:3',18:'720×576p 50 Hz 16:9',19:'1280×720p 50 Hz',
|
||||
20:'1920×1080i 50 Hz',31:'1920×1080p 50 Hz',32:'1920×1080p 24 Hz',
|
||||
33:'1920×1080p 25 Hz',34:'1920×1080p 30 Hz',60:'1280×720p 24 Hz',
|
||||
61:'1280×720p 25 Hz',62:'1280×720p 30 Hz',63:'1920×1080p 120 Hz',64:'1920×1080p 100 Hz',
|
||||
93:'3840×2160p 24 Hz',94:'3840×2160p 25 Hz',95:'3840×2160p 30 Hz',
|
||||
96:'3840×2160p 50 Hz',97:'3840×2160p 60 Hz',98:'4096×2160p 24 Hz',
|
||||
99:'4096×2160p 25 Hz',100:'4096×2160p 30 Hz',101:'4096×2160p 50 Hz',102:'4096×2160p 60 Hz',
|
||||
117:'3840×2160p 100 Hz',118:'3840×2160p 120 Hz'}
|
||||
EST = ['720×400 70 Hz','720×400 88 Hz','640×480 60 Hz','640×480 67 Hz','640×480 72 Hz',
|
||||
'640×480 75 Hz','800×600 56 Hz','800×600 60 Hz','800×600 72 Hz','800×600 75 Hz',
|
||||
'832×624 75 Hz','1024×768i 87 Hz','1024×768 60 Hz','1024×768 70 Hz','1024×768 75 Hz',
|
||||
'1280×1024 75 Hz','1152×870 75 Hz']
|
||||
|
||||
def read_file(path):
|
||||
from pathlib import Path
|
||||
data = Path(path).read_bytes()
|
||||
if len(data) > 32768 * 4:
|
||||
raise ValueError('文件过大:最大 128 KiB')
|
||||
if Path(path).suffix.lower() in ('.hex', '.txt'):
|
||||
s = data.decode('utf-8-sig').strip()
|
||||
s = re.sub(r'0[xX]([0-9a-fA-F]{2})', r'\1', s)
|
||||
s = re.sub(r'[\s,{};]', '', s)
|
||||
if not re.fullmatch(r'(?:[0-9a-fA-F]{2})+', s):
|
||||
raise ValueError('HEX 文件仅接受十六进制字节,不接受偏移地址或 ASCII 栏')
|
||||
return bytes.fromhex(s)
|
||||
return data
|
||||
|
||||
def hex_dump(raw):
|
||||
return '\n'.join(f'{i:04X} ' + ' '.join(f'{x:02X}' for x in raw[i:i+16]) +
|
||||
' |' + ''.join(chr(x) if 32 <= x < 127 else '.' for x in raw[i:i+16]) + '|'
|
||||
for i in range(0, len(raw), 16))
|
||||
|
||||
def dtd(b, source):
|
||||
clock = int.from_bytes(b[:2], 'little') * 10000
|
||||
h = b[2] + ((b[4] & 240) << 4); hb = b[3] + ((b[4] & 15) << 8)
|
||||
v = b[5] + ((b[7] & 240) << 4); vb = b[6] + ((b[7] & 15) << 8)
|
||||
hf = b[8] + ((b[11] & 192) << 2); hs = b[9] + ((b[11] & 48) << 4)
|
||||
vf = (b[10] >> 4) + ((b[11] & 12) << 2); vs = (b[10] & 15) + ((b[11] & 3) << 4)
|
||||
interlace = bool(b[17] & 128)
|
||||
total_v = (v + vb) * 2 + 1 if interlace else v + vb
|
||||
rate = clock / ((h + hb) * total_v) if h + hb and total_v else 0
|
||||
sync = (b[17] >> 3) & 3
|
||||
polarity = ('H' + ('+' if b[17] & 2 else '-') + ' V' + ('+' if b[17] & 4 else '-')) if sync == 3 else '非数字分离同步'
|
||||
return {'来源':source, '分辨率':f'{h}×{v*2 if interlace else v}' + ('i' if interlace else 'p'),
|
||||
'刷新率':f'{rate*2 if interlace else rate:.3f} Hz' + ('(场率)' if interlace else ''),
|
||||
'像素时钟 MHz':clock / 1e6, '水平有效/前肩/同步/后肩':[h,hf,hs,hb-hf-hs],
|
||||
'垂直有效/前肩/同步/后肩(隔行按场)':[v,vf,vs,vb-vf-vs], '同步':polarity}
|
||||
|
||||
def parse(raw):
|
||||
if len(raw) < 128:
|
||||
raise ValueError(f'EDID 基础块不足 128 字节,实际 {len(raw)}')
|
||||
r = {'基本信息':{}, '时序':[], '能力':[], '数据块':[], '校验':[], '警告':[]}
|
||||
info, warnings = r['基本信息'], r['警告']
|
||||
if raw[:8] != bytes.fromhex('00 ff ff ff ff ff ff 00'):
|
||||
warnings.append('EDID 文件头无效;以下解析仅供诊断')
|
||||
if raw[18] != 1:
|
||||
warnings.append('非 EDID 1.x:未解析,仅保留原始数据')
|
||||
return r
|
||||
if len(raw) % 128: warnings.append('末尾存在不足 128 字节的数据')
|
||||
expected = (raw[126] + 1) * 128
|
||||
if expected != len(raw): warnings.append(f'声明 {expected} 字节,实际 {len(raw)} 字节')
|
||||
for n in range(len(raw)//128):
|
||||
block = raw[n*128:(n+1)*128]
|
||||
r['校验'].append(f'块 {n}: ' + ('通过' if sum(block)%256 == 0 else '失败'))
|
||||
pnp = int.from_bytes(raw[8:10], 'big')
|
||||
manufacturer = ''.join(chr(((pnp >> s) & 31) + 64) for s in (10,5,0))
|
||||
info.update({'厂家 PNP 代码':manufacturer, '产品编号':f'0x{int.from_bytes(raw[10:12], "little"):04X}',
|
||||
'数字序列号':int.from_bytes(raw[12:16], 'little'), 'EDID 版本':f'{raw[18]}.{raw[19]}',
|
||||
'生产日期':f'{raw[17]+1990} 年 / '+('型号年份' if raw[16]==255 else f'第 {raw[16]} 周' if raw[16] else '周未指定'),
|
||||
'尺寸':f'{raw[21]} × {raw[22]} cm' if raw[21] and raw[22] else '未给出完整物理尺寸(可能编码宽高比)',
|
||||
'Gamma':(raw[23]+100)/100 if raw[23]!=255 else '未定义',
|
||||
'扩展块数':raw[126], 'SHA-256':hashlib.sha256(raw).hexdigest()})
|
||||
if raw[20]&128:
|
||||
info['输入类型'] = '数字'
|
||||
if raw[19]>=4:
|
||||
info['接口声明'] = {0:'未定义',1:'DVI',2:'HDMI-A',3:'HDMI-B',4:'MDDI',5:'DisplayPort'}.get(raw[20]&15,'保留')
|
||||
info['色深 bpc'] = {0:'未定义',1:6,2:8,3:10,4:12,5:14,6:16}.get((raw[20]>>4)&7,'保留')
|
||||
else: info['输入类型'] = '模拟'
|
||||
for idx, mode in enumerate(EST):
|
||||
if raw[35+idx//8] & (128>>(idx%8)): r['时序'].append({'来源':'已建立时序','分辨率':mode})
|
||||
for i in range(38,54,2):
|
||||
a,b = raw[i:i+2]
|
||||
if (a,b) in ((1,1),(0,0)): continue
|
||||
h = (a+31)*8
|
||||
ratio = (1 if raw[19]<3 else 10/16, 3/4, 4/5, 9/16)[b>>6]
|
||||
r['时序'].append({'来源':'标准时序','分辨率':f'{h}×{round(h*ratio)}','刷新率':f'{(b&63)+60} Hz'})
|
||||
for offset in range(54,126,18):
|
||||
b=raw[offset:offset+18]
|
||||
if any(b[:2]):
|
||||
r['时序'].append(dtd(b, '基础 DTD'+('(首选)' if offset==54 and (raw[19]>=4 or raw[24]&2) else '')))
|
||||
elif b[:3]==b'\0\0\0':
|
||||
if b[3] in (252,255,254): info[{252:'显示器名称',255:'文本序列号',254:'描述文本'}[b[3]]] = b[5:18].decode('ascii','replace').strip('\x00\n ')
|
||||
elif b[3]==253:
|
||||
info['频率范围原始值'] = f'垂直 {b[5]}–{b[6]} Hz;水平 {b[7]}–{b[8]} kHz;最大像素时钟 {b[9]*10} MHz'
|
||||
if b[4]: warnings.append('范围描述符有扩展偏移,频率范围显示原始字段,暂未应用偏移')
|
||||
coords=[]
|
||||
low=[(raw[25]>>s)&3 for s in (6,4,2,0)]+[(raw[26]>>s)&3 for s in (6,4,2,0)]
|
||||
for i,name in enumerate(('Rx','Ry','Gx','Gy','Bx','By','Wx','Wy')): coords.append(f'{name}={(raw[27+i]*4+low[i])/1024:.4f}')
|
||||
r['能力'].append('色度坐标:'+', '.join(coords))
|
||||
for n in range(1,min(len(raw)//128,raw[126]+1)):
|
||||
b=raw[n*128:(n+1)*128]
|
||||
r['数据块'].append({'块':n,'类型':{2:'CTA-861',112:'DisplayID(暂未解码)'}.get(b[0],f'未知 0x{b[0]:02X}'),'原始':b.hex(' ')})
|
||||
if b[0]!=2: continue
|
||||
r['能力'].append(f'CTA 块 {n} / revision {b[1]}:'+', '.join(label for bit,label in ((64,'基本音频'),(32,'YCbCr 4:4:4'),(16,'YCbCr 4:2:2')) if b[3]&bit))
|
||||
end=b[2]
|
||||
if end==0: continue
|
||||
if not 4<=end<=127:
|
||||
warnings.append(f'CTA 块 {n} DTD 偏移无效:{end}'); continue
|
||||
p=4
|
||||
while p<end:
|
||||
tag,length=b[p]>>5,b[p]&31
|
||||
if not b[p]: break
|
||||
if p+1+length>end:
|
||||
warnings.append(f'CTA 块 {n} 数据块越界 @{p}'); break
|
||||
x=b[p+1:p+1+length]; p+=length+1
|
||||
entry={'块':n,'tag':tag,'原始':x.hex(' ')}; r['数据块'].append(entry)
|
||||
if tag==2:
|
||||
for svd in x:
|
||||
vic=svd&127 if 129<=svd<=192 else svd
|
||||
r['时序'].append({'来源':f'CTA VIC {vic}'+('(原生)' if 129<=svd<=192 else ''),'分辨率':VICS.get(vic,'尚未映射;查看 VIC 编号')})
|
||||
elif tag==1:
|
||||
if len(x)%3: warnings.append('音频数据块长度不是 3 的倍数')
|
||||
for j in range(0,len(x)-2,3):
|
||||
a,f,c=x[j:j+3]; fmt=(a>>3)&15
|
||||
rates='/'.join(str(v) for k,v in enumerate((32,44.1,48,88.2,96,176.4,192)) if f&(1<<k))
|
||||
bits='/'.join(str(v) for k,v in enumerate((16,20,24)) if c&(1<<k))
|
||||
r['能力'].append(f'音频:'+{1:'LPCM',2:'AC-3',7:'DTS',10:'E-AC-3',11:'DTS-HD',12:'MAT'}.get(fmt,f'格式代码 {fmt}')+f' / {(a&7)+1} 声道 / {rates} kHz'+(f' / {bits} bit' if fmt==1 else f' / 格式字段 0x{c:02X}'))
|
||||
elif tag==3 and len(x)>=3:
|
||||
oui=int.from_bytes(x[:3],'little'); entry['OUI']=f'{oui:06X}'
|
||||
if oui==0x000c03:
|
||||
r['能力'].append('HDMI VSDB')
|
||||
if len(x)>=5: r['能力'].append('CEC 物理地址:'+'.'.join(str(v) for v in (x[3]>>4,x[3]&15,x[4]>>4,x[4]&15)))
|
||||
if len(x)>=6: r['能力'].append('HDMI 深色:'+('/'.join(str(v) for k,v in ((4,10),(5,12),(6,16)) if x[5]&(1<<k)) or '未声明')+' bpc')
|
||||
if len(x)>=7 and x[6]: r['能力'].append(f'最大 TMDS 时钟:{x[6]*5} MHz')
|
||||
elif oui==0xc45dd8:
|
||||
r['能力'].append('HDMI Forum VSDB(VRR/FRL/DSC 字段暂未解码)')
|
||||
if len(x)>=5 and x[4]: r['能力'].append(f'最大 TMDS 字符速率:{x[4]*5} MHz')
|
||||
if len(x)>=6: r['能力'].append('SCDC:'+('支持' if x[5]&128 else '未声明'))
|
||||
elif tag==7 and x:
|
||||
entry['扩展 tag']=x[0]
|
||||
if x[0]==6 and len(x)>=3:
|
||||
r['能力'].append('HDR EOTF:'+', '.join(name for k,name in enumerate(('传统 SDR','传统 HDR','PQ / ST 2084','HLG')) if x[1]&(1<<k)))
|
||||
r['能力'].append(f'HDR 静态元数据类型位图:0x{x[2]:02X}')
|
||||
elif x[0]==14:
|
||||
r['能力'].append('仅 YCbCr 4:2:0 VIC:'+', '.join(f'{v} ({VICS.get(v,"未映射")})' for v in x[1:]))
|
||||
elif x[0]==15: r['能力'].append('YCbCr 4:2:0 能力映射:'+x[1:].hex(' ')+'(按本 CTA SVD 顺序)')
|
||||
elif x[0]==5 and len(x)>=3:
|
||||
r['能力'].append('色彩标准:'+', '.join(name for k,name in enumerate(('xvYCC601','xvYCC709','sYCC601','opYCC601','opRGB','BT.2020 cYCC','BT.2020 YCC','BT.2020 RGB')) if x[1]&(1<<k)))
|
||||
for o in range(end,110,18):
|
||||
if any(b[o:o+2]): r['时序'].append(dtd(b[o:o+18],f'CTA {n} DTD'))
|
||||
return r
|
||||
Reference in New Issue
Block a user