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:
2026-09-24 22:34:02 +08:00
parent 74b64dcfd5
commit 68d70497d4
26 changed files with 1468 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
把 src/main.py 打包成单文件 exe(工具名: EDID-Viewer)
================================================
与 DLPilot/src/build_exe.py 同一套路: onefile + --noconsole, Python 解释器、
PySide6 和 PySide6-Fluent-Widgets 全部打进一个 exe, 目标机器无需安装依赖。
用法:
python build_exe.py 自动安装 PyInstaller(如缺)并打包
python build_exe.py --debug 额外打一个带控制台的变体
产物: release/EDID-Viewer.exe
spec 由本脚本生成到 src/build/(不入库), 调整打包逻辑直接改这里的模板。
"""
import os
import subprocess
import sys
# 本脚本位于 src/, 打包产物输出到工具根的 release/
SRC = os.path.dirname(os.path.abspath(__file__))
BASE = os.path.dirname(SRC)
RELEASE = os.path.join(BASE, 'release')
WORK = os.path.join(SRC, 'build')
ICO = os.path.join(BASE, 'assets', 'edid-viewer.ico')
SPEC_TEMPLATE = """\
# -*- coding: utf-8 -*-
# 由 build_exe.py 生成, 不要手改; 调整打包逻辑请改 src/build_exe.py。
from PyInstaller.utils.hooks import collect_all
qfluent_datas, qfluent_binaries, qfluent_hidden = collect_all('qfluentwidgets')
a = Analysis(
[r'{entry}'], pathex=[r'{src}'], binaries=qfluent_binaries,
datas=qfluent_datas + {icon_datas}, hiddenimports=qfluent_hidden,
noarchive=False)
# Qt 6.11 使用 Windows 非版本化 ICU 导出; PATH 上 Poppler/Conda 的版本化
# ICU(icuuc.dll/icudt78.dll)会被 PyInstaller 误收, 令 QtCore.pyd 启动报
# WinError 127 —— 打包时剔除, 运行时用系统自带 ICU。
a.binaries = [b for b in a.binaries if b[0].lower() not in ('icuuc.dll', 'icudt78.dll')]
pyz = PYZ(a.pure)
exe = EXE(pyz, a.scripts, a.binaries, a.datas, [], name='{name}',
debug=False, strip=False, upx=False, console={console}{icon_clause})
"""
def ensure_pyinstaller():
try:
import PyInstaller # noqa: F401
return
except ImportError:
pass
print('PyInstaller 未安装, 正在安装...')
subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'pyinstaller'])
def remove_stale(exe_path):
"""删除旧 exe; 若被占用(上次验证的进程还在), 先结束同名进程再重试"""
if not os.path.exists(exe_path):
return
try:
os.remove(exe_path)
return
except PermissionError:
pass
import time
print(f'{exe_path} 被占用, 结束残留进程后重试...')
subprocess.run(['taskkill', '/F', '/IM', os.path.basename(exe_path)],
capture_output=True)
time.sleep(1.0)
os.remove(exe_path)
def build(name, console):
remove_stale(os.path.join(RELEASE, name + '.exe'))
if os.path.exists(ICO):
icon_datas = f"[(r'{ICO}', 'assets')]"
icon_clause = f", icon=r'{ICO}'"
else:
print('警告: 未找到 assets/edid-viewer.ico(先运行 assets/make_icon.py), 本次打包不带图标')
icon_datas, icon_clause = '[]', ''
spec = SPEC_TEMPLATE.format(entry=os.path.join(SRC, 'main.py'), src=SRC,
icon_datas=icon_datas, name=name,
console=console, icon_clause=icon_clause)
os.makedirs(WORK, exist_ok=True)
spec_path = os.path.join(WORK, name + '.spec')
with open(spec_path, 'w', encoding='utf-8') as f:
f.write(spec)
cmd = [sys.executable, '-m', 'PyInstaller', '--noconfirm', '--clean',
'--distpath', RELEASE, '--workpath', WORK, spec_path]
print('\n>>', ' '.join(cmd))
subprocess.check_call(cmd)
out = os.path.join(RELEASE, name + '.exe')
print(f'完成: {out} ({os.path.getsize(out) / 1024 / 1024:.1f} MB)')
def main():
build_console = '--debug' in sys.argv
ensure_pyinstaller()
# 常规版: 双击即用, 无黑框
build('EDID-Viewer', 'False')
if build_console:
# 调试版: 带控制台, 异常栈可见
build('EDID-Viewer-console', 'True')
if __name__ == '__main__':
main()
+157
View File
@@ -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
+391
View File
@@ -0,0 +1,391 @@
"""Fluent EDID viewer for Windows display devices and offline files."""
import ctypes
import json
import sys
from pathlib import Path
from PySide6.QtCore import QThread, Qt, QTimer, Signal
from PySide6.QtGui import QFont, QFontDatabase, QIcon
from PySide6.QtWidgets import (
QApplication,
QFileDialog,
QHBoxLayout,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QSizePolicy,
QSplitter,
QTabWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from qfluentwidgets import (
BodyLabel,
CaptionLabel,
CardWidget,
ComboBox,
FluentIcon,
PlainTextEdit,
PrimaryPushButton,
PushButton,
StrongBodyLabel,
SubtitleLabel,
TreeWidget,
)
from edid import hex_dump, parse, read_file
from theme import ThemeManager
from windows_edid import scan
APP_NAME = "EDID Viewer"
APP_VERSION = "0.1.0"
REPO_URL = "https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools"
def icon_path():
"""Packaged onefile: icon is extracted under sys._MEIPASS/assets; source: ../assets."""
if hasattr(sys, "_MEIPASS"):
return Path(sys._MEIPASS) / "assets" / "edid-viewer.ico"
return Path(__file__).resolve().parent.parent / "assets" / "edid-viewer.ico"
class Scanner(QThread):
result = Signal(object)
failure = Signal(str)
def run(self):
try:
self.result.emit(scan())
except Exception as exc:
self.failure.emit(str(exc))
class Window(QMainWindow):
"""Main Fluent surface; parser and Windows access remain separate modules."""
def __init__(self, theme_manager=None):
super().__init__()
self.theme_manager = theme_manager or ThemeManager(QApplication.instance())
self.setWindowTitle("EDID Viewer · HDMI 显示信息")
self.resize(1240, 820)
self.setMinimumSize(900, 620)
self.raw = b""
self.report = {}
self.worker = None
self._build_ui()
self.theme_manager.set_window(self)
self.bin_button.setEnabled(False)
self.export_button.setEnabled(False)
QTimer.singleShot(0, self.start_scan)
def _button(self, text, callback, primary=False, icon=None):
button = PrimaryPushButton(self) if primary else PushButton(self)
button.setText(text)
if icon is not None:
button.setIcon(icon)
button.clicked.connect(callback)
return button
def _card(self, title, subtitle=None, parent=None):
card = CardWidget(parent or self)
layout = QVBoxLayout(card)
layout.setContentsMargins(16, 14, 16, 16)
layout.setSpacing(9)
layout.addWidget(StrongBodyLabel(title, card))
if subtitle:
caption = CaptionLabel(subtitle, card)
caption.setWordWrap(True)
layout.addWidget(caption)
return card, layout
def _text(self, mono=False, parent=None):
text = PlainTextEdit(parent or self)
text.setReadOnly(True)
text.setLineWrapMode(QPlainTextEdit.NoWrap)
if mono:
text.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
return text
def _build_ui(self):
root = QWidget(self)
root.setObjectName("root")
outer = QVBoxLayout(root)
outer.setContentsMargins(22, 18, 22, 18)
outer.setSpacing(14)
header = QHBoxLayout()
title_box = QVBoxLayout()
title_box.setSpacing(1)
title_box.addWidget(SubtitleLabel("EDID Viewer", root))
title_box.addWidget(BodyLabel("显示设备能力与原始数据", root))
header.addLayout(title_box)
header.addStretch(1)
header.addWidget(BodyLabel("外观", root))
self.theme_choice = ComboBox(root)
self.theme_choice.setAccessibleName("外观主题")
for label, value in (
("跟随系统", "system"),
("Light · 浅色", "light"),
("Dark · 深色", "dark"),
("dark1 · 蓝色", "dark1"),
("dark2 · 青色", "dark2"),
):
self.theme_choice.addItem(label)
self.theme_choice.setItemData(self.theme_choice.count() - 1, value)
self.theme_choice.setCurrentIndex(self.theme_choice.findData(self.theme_manager.mode))
self.theme_choice.currentIndexChanged.connect(
lambda: self.theme_manager.set_mode(self.theme_choice.currentData())
)
header.addWidget(self.theme_choice)
header.addWidget(self._button("关于", self.show_about))
outer.addLayout(header)
note = CaptionLabel(
"Windows 驱动提供的 EDID,可能包含缓存或覆盖;EDID 是接收端声明能力,不代表当前实际输出。",
root,
)
note.setWordWrap(True)
outer.addWidget(note)
split = QSplitter(Qt.Vertical, root)
split.setChildrenCollapsible(False)
split.setHandleWidth(8)
split.addWidget(self._build_sidebar())
split.addWidget(self._build_content())
split.setStretchFactor(0, 0)
split.setStretchFactor(1, 1)
split.setSizes([150, 620])
outer.addWidget(split, 1)
self.setCentralWidget(root)
def _build_sidebar(self):
host = QWidget(self)
host.setMinimumHeight(132)
host.setMaximumHeight(210)
layout = QHBoxLayout(host)
layout.setContentsMargins(2, 2, 2, 2)
layout.setSpacing(12)
card, body = self._card("显示设备", "选择活动输出并读取驱动提供的原始 EDID。", host)
card.setMinimumWidth(360)
self.devices = ComboBox(card)
self.devices.setMinimumHeight(38)
self.devices.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
device_row = QHBoxLayout()
device_row.setSpacing(8)
device_row.addWidget(self.devices, 1)
self.refresh_button = self._button("刷新设备", self.start_scan, icon=FluentIcon.SYNC)
self.refresh_button.setMinimumWidth(116)
device_row.addWidget(self.refresh_button)
body.addLayout(device_row)
layout.addWidget(card)
card, body = self._card("离线文件", "适用于 .bin、.edid、.hex 和纯十六进制 .txt。", host)
card.setMinimumWidth(430)
self.open_button = self._button("打开 EDID", self.open_file, primary=True, icon=FluentIcon.FOLDER)
self.bin_button = self._button("导出 BIN", self.save_bin, icon=FluentIcon.DOWNLOAD)
self.export_button = self._button("导出 JSON 报告", self.save_report, icon=FluentIcon.SAVE)
file_row = QHBoxLayout()
file_row.setSpacing(8)
for button in (self.open_button, self.bin_button, self.export_button):
button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
file_row.addWidget(button, 1)
body.addLayout(file_row)
layout.addWidget(card)
layout.setStretch(0, 1)
layout.setStretch(1, 1)
return host
def _build_content(self):
card = CardWidget(self)
layout = QVBoxLayout(card)
layout.setContentsMargins(18, 16, 18, 18)
layout.setSpacing(12)
self.summary = BodyLabel("正在读取设备…", card)
self.summary.setWordWrap(True)
layout.addWidget(self.summary)
self.tabs = QTabWidget(card)
self.tabs.setDocumentMode(True)
self.tree = TreeWidget(card)
self.tree.setHeaderLabels(["项目", "详细信息"])
self.tree.setColumnWidth(0, 260)
self.tree.setAlternatingRowColors(True)
self.tabs.addTab(self.tree, "设备与能力")
self.timings = TreeWidget(card)
self.timings.setHeaderLabels(["来源 / 字段", "分辨率 / 数值", "刷新率"])
self.timings.setColumnWidth(0, 255)
self.timings.setColumnWidth(1, 360)
self.timings.setAlternatingRowColors(True)
self.tabs.addTab(self.timings, "分辨率与时序")
self.hex = self._text(mono=True, parent=card)
self.tabs.addTab(self.hex, "原始 HEX")
self.details = self._text(mono=True, parent=card)
self.tabs.addTab(self.details, "完整解析 JSON")
layout.addWidget(self.tabs, 1)
self.devices.currentIndexChanged.connect(self.select_device)
return card
def show_about(self):
QMessageBox.about(
self,
f"关于 {APP_NAME}",
f"{APP_NAME} v{APP_VERSION}\n\n"
"Windows 显示器 EDID 查看器 · 只读,不修改显示设置\n"
"PySide6-Fluent-Widgets UI\n\n"
f"{REPO_URL}",
)
def start_scan(self):
if self.worker and self.worker.isRunning():
return
self.refresh_button.setEnabled(False)
self.open_button.setEnabled(False)
self.statusBar().showMessage("正在读取活动设备…")
self.worker = Scanner(self)
self.worker.result.connect(self.scanned)
self.worker.failure.connect(self.scan_failed)
self.worker.finished.connect(lambda: self.refresh_button.setEnabled(True))
self.worker.finished.connect(lambda: self.open_button.setEnabled(True))
self.worker.start()
def scan_failed(self, message):
self.statusBar().showMessage("设备读取失败,可打开离线 EDID 文件")
self.summary.setText("Windows EDID 读取失败:" + message)
def scanned(self, items):
old = self.devices.currentData()
old_id = old.get("instance") if old else None
self.devices.blockSignals(True)
self.devices.clear()
selected = 0
for index, item in enumerate(items):
name = item["instance"]
try:
info = parse(item["raw"])["基本信息"]
name = info.get("显示器名称", info.get("厂家 PNP 代码", name))
except ValueError:
pass
self.devices.addItem(
f'{item["连接"]} · {name} · {item["instance"]}',
userData=item,
)
if item["instance"] == old_id:
selected = index
self.devices.setCurrentIndex(selected)
self.devices.blockSignals(False)
self.select_device()
self.statusBar().showMessage(f"找到 {len(items)} 个活动设备;未启用的输出可能不会列出")
def select_device(self, *_):
item = self.devices.currentData()
if item:
self.show_raw(item["raw"], item["instance"] + " / " + item["连接"], item["error"])
else:
self.show_raw(b"", "未找到活动显示器。可刷新或打开离线文件。")
def show_raw(self, raw, source, error=""):
self.raw = raw
self.report = {}
self.tree.clear()
self.timings.clear()
self.details.clear()
self.hex.setPlainText(hex_dump(raw))
self.bin_button.setEnabled(bool(raw))
self.export_button.setEnabled(False)
try:
report = parse(raw)
except ValueError as exc:
self.summary.setText(f"{source}\n{error or str(exc)}")
return
self.report = {"来源": source, "读取错误": error, **report, "原始 HEX": raw.hex(" ")}
self.export_button.setEnabled(True)
messages = report["警告"] + [item for item in report["校验"] if "失败" in item]
if error:
messages.insert(0, error)
status = "各块校验通过" if not messages else "⚠ " + ";".join(messages)
self.summary.setText(f"{source}\n{len(raw)} 字节 · {status}")
for group in ("基本信息", "能力", "校验", "警告", "数据块"):
parent = QTreeWidgetItem(self.tree, [group])
value = report[group]
rows = value.items() if isinstance(value, dict) else enumerate(value, 1)
for key, item in rows:
rendered = json.dumps(item, ensure_ascii=False) if isinstance(item, (dict, list)) else str(item)
QTreeWidgetItem(parent, [str(key), rendered])
parent.setExpanded(group != "数据块")
for timing in report["时序"]:
parent = QTreeWidgetItem(self.timings, [timing["来源"], timing["分辨率"], timing.get("刷新率", "")])
for key, value in timing.items():
if key not in ("来源", "分辨率", "刷新率"):
QTreeWidgetItem(parent, [key, str(value)])
self.details.setPlainText(json.dumps(self.report, ensure_ascii=False, indent=2))
def open_path(self, path):
try:
raw = read_file(path)
self.devices.blockSignals(True)
self.devices.setCurrentIndex(-1)
self.devices.blockSignals(False)
self.show_raw(raw, "文件:" + str(path))
except (OSError, ValueError, UnicodeError) as exc:
QMessageBox.warning(self, "无法打开", str(exc))
def open_file(self):
path, _ = QFileDialog.getOpenFileName(
self,
"打开 EDID",
"",
"EDID (*.bin *.edid *.hex *.txt);;全部文件 (*)",
)
if path:
self.open_path(path)
def save_bin(self):
path, _ = QFileDialog.getSaveFileName(self, "导出原始 EDID", "edid.bin", "BIN (*.bin)")
if path:
self.write(path, self.raw)
def save_report(self):
path, _ = QFileDialog.getSaveFileName(self, "导出报告", "edid-report.json", "JSON (*.json)")
if path:
self.write(path, json.dumps(self.report, ensure_ascii=False, indent=2).encode("utf-8"))
def write(self, path, data):
try:
Path(path).write_bytes(data)
self.statusBar().showMessage("已保存:" + path)
except OSError as exc:
QMessageBox.warning(self, "保存失败", str(exc))
def closeEvent(self, event):
if self.worker and self.worker.isRunning():
self.statusBar().showMessage("正在读取,请等待读取完成后关闭(最长约 45 秒)")
event.ignore()
else:
event.accept()
def main():
if sys.platform == "win32":
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("LT8619C.Tools.EDIDViewer")
app = QApplication(sys.argv)
app.setApplicationName(APP_NAME)
app.setFont(QFont("Microsoft YaHei UI", 10))
app.setWindowIcon(QIcon(str(icon_path())))
theme_manager = ThemeManager(app)
window = Window(theme_manager)
window.show()
return app.exec()
if __name__ == "__main__":
sys.exit(main())
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
组装 EDID-Viewer 发布包: exe + README → 一个 zip(解压即用)
================================================
把主 README 复制进 release/ 后, 与单文件 exe 一起压成 zip —— 两者内容一致。
zip 解压后根目录为 EDID-Viewer/:
EDID-Viewer.exe 主程序(先运行 build_exe.py 打包)
README.md 使用说明
用法:
python package_release.py 版本号自动取最近 git tag(EDID-Viewer-v*)
python package_release.py 0.1.0 手动指定版本号
产物: release/EDID-Viewer-v<版本>-win64.zip(zip 不入库, 上传到 Gitea Release)
"""
import os
import shutil
import subprocess
import sys
import zipfile
SRC = os.path.dirname(os.path.abspath(__file__))
BASE = os.path.dirname(SRC)
RELEASE = os.path.join(BASE, 'release')
def detect_version():
"""从最近的 EDID-Viewer-v* tag 推断版本号, 无 tag 则报错提示手动指定"""
try:
out = subprocess.check_output(
['git', 'describe', '--tags', '--abbrev=0', '--match', 'EDID-Viewer-v*'],
cwd=BASE, stderr=subprocess.DEVNULL)
return out.decode().strip().removeprefix('EDID-Viewer-v')
except Exception:
sys.exit('未能从 git tag 推断版本号, 请手动指定: python package_release.py <版本号>')
def main():
version = (sys.argv[1] if len(sys.argv) > 1 else detect_version()).lstrip('v')
exe = os.path.join(RELEASE, 'EDID-Viewer.exe')
readme = os.path.join(BASE, 'README.md')
if not os.path.exists(exe):
sys.exit('缺少 release/EDID-Viewer.exe, 先运行: python build_exe.py')
if not os.path.exists(readme):
sys.exit(f'缺少 {readme}')
shutil.copy2(readme, os.path.join(RELEASE, 'README.md'))
zip_name = f'EDID-Viewer-v{version}-win64.zip'
zip_path = os.path.join(RELEASE, zip_name)
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z:
z.write(exe, f'EDID-Viewer/{os.path.basename(exe)}')
z.write(os.path.join(RELEASE, 'README.md'), 'EDID-Viewer/README.md')
print(f'发布文件夹: {RELEASE} (exe + README)')
print(f'发布包: {zip_path} ({os.path.getsize(zip_path) / 1024 / 1024:.1f} MB)')
print('下一步上传: python publish_gitea.py', zip_name)
if __name__ == '__main__':
main()
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
创建/更新 Gitea Release 并上传发布 zip(纯标准库, 无需安装依赖)
================================================================
用法:
python publish_gitea.py EDID-Viewer-v0.1.0-win64.zip
python publish_gitea.py <zip> --notes-file release/notes-v0.1.0.md
python publish_gitea.py <zip> --tag EDID-Viewer-v0.1.0 --replace
行为:
- tag 与版本默认取 zip 文件名(EDID-Viewer-vX.Y.Z-win64.zip → EDID-Viewer-vX.Y.Z)
- release 不存在则创建(tag 不存在时 Gitea 在默认分支头自动建 tag);
已存在则复用, 仅上传/更新资产
- release 上已有同名资产时: 默认跳过, --replace 删旧再传
- API token: --token 参数或环境变量 GITEA_TOKEN(需该仓库写权限)
"""
import argparse
import json
import os
import re
import ssl
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
DEFAULT_NOTES = """## EDID-Viewer {version}
Windows 显示器 EDID 查看器(LT8619C 调试工具集配套): 只读查看活动显示设备
或离线文件的 EDID —— 厂家/序列号/分辨率时序、CTA/HDMI/音频/HDR 能力、
校验诊断, 支持 BIN/HEX 导入与 BIN/JSON 报告导出。
### 安装
下载 `EDID-Viewer-v{version}-win64.zip`, 解压后双击 `EDID-Viewer.exe`(Windows 10/11 x64, 免安装)。
### 包内容
- `EDID-Viewer.exe` — 主程序
- `README.md` — 完整使用说明
"""
def git_remote_info():
"""从 origin 远程地址解析 API base 与 owner/repo(ssh 端口不影响 https API)"""
url = subprocess.check_output(['git', 'remote', 'get-url', 'origin']).decode().strip()
m = re.match(r'(?:ssh://git@|git@)([\w.-]+)(?::\d+)?[:/]([\w.-]+)/([\w.-]+?)(?:\.git)?$', url)
if not m:
sys.exit(f'无法从 origin 解析 Gitea 地址: {url}')
host, owner, repo = m.groups()
return f'https://{host}', owner, repo
class Api:
def __init__(self, base, owner, repo, token, insecure):
self.api = f'{base}/api/v1/repos/{owner}/{repo}'
self.headers = {'Authorization': f'token {token}'}
self.ctx = ssl._create_unverified_context() if insecure else None
def request(self, method, path, data=None, headers=None, retries=4):
"""发请求; 实例 TLS 间歇性抽风(SSL UNEXPECTED_EOF), 连接级错误自动重试"""
last = None
for attempt in range(retries):
req = urllib.request.Request(self.api + path, data=data, method=method,
headers={**self.headers, **(headers or {})})
try:
with urllib.request.urlopen(req, context=self.ctx) as resp:
body = resp.read()
return resp.status, json.loads(body) if body else None
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b'{}')
except (urllib.error.URLError, ssl.SSLError, OSError) as e:
last = e
time.sleep(1.0 + attempt)
raise RuntimeError(f'Gitea 请求连续 {retries} 次失败: {last}')
def get_json(self, path):
status, data = self.request('GET', path)
return data if status == 200 else None
def upload_asset(self, release_id, filepath, replace):
name = os.path.basename(filepath)
status, rel = self.request('GET', f'/releases/{release_id}')
if status != 200:
sys.exit(f'读取 release {release_id} 失败: {status} {rel}')
# Gitea 版本差异: assets 可能是 {items:[...]} 或纯数组
assets = rel.get('assets') or []
if isinstance(assets, dict):
assets = assets.get('items', [])
for asset in assets:
if asset['name'] == name:
if not replace:
print(f'资产 {name} 已存在, 跳过(覆盖请加 --replace)')
return
self.request('DELETE', f"/releases/{release_id}/assets/{asset['id']}")
print(f'已删除旧资产 {name}')
boundary = uuid.uuid4().hex
payload = open(filepath, 'rb').read()
body = (f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'
f'Content-Type: application/octet-stream\r\n\r\n').encode() + payload + \
f'\r\n--{boundary}--\r\n'.encode()
status, data = self.request(
'POST', f'/releases/{release_id}/assets?name={name}', body,
{'Content-Type': f'multipart/form-data; boundary={boundary}'})
if status != 201:
sys.exit(f'上传资产失败: {status} {data}')
print(f'已上传资产: {name} ({len(payload) / 1024 / 1024:.1f} MB)')
def main():
ap = argparse.ArgumentParser()
ap.add_argument('zipfile_path', help='发布 zip(release/ 下)')
ap.add_argument('--tag', help='release 标签, 默认取 zip 文件名去掉 -win64.zip')
ap.add_argument('--notes-file', help='release 说明 markdown 文件, 默认用内置模板')
ap.add_argument('--token', default=os.environ.get('GITEA_TOKEN'), help='API token')
ap.add_argument('--replace', action='store_true', help='覆盖已存在的同名资产')
ap.add_argument('--insecure', action='store_true', help='跳过 TLS 证书校验(自签证书)')
args = ap.parse_args()
if not os.path.exists(args.zipfile_path):
sys.exit(f'找不到 {args.zipfile_path}')
if not args.token:
sys.exit('缺少 API token: 传 --token 或设环境变量 GITEA_TOKEN')
tag = args.tag or re.sub(r'-win64\.zip$', '', os.path.basename(args.zipfile_path))
version = tag.removeprefix('EDID-Viewer-v')
notes = open(args.notes_file, encoding='utf-8').read() if args.notes_file \
else DEFAULT_NOTES.format(version=version)
base, owner, repo = git_remote_info()
api = Api(base, owner, repo, args.token, args.insecure)
print(f'目标: {base}/{owner}/{repo} tag={tag}')
rel = api.get_json(f'/releases/tags/{tag}')
if rel:
print(f'release 已存在(id={rel["id"]}), 复用')
else:
status, rel = api.request('POST', '/releases', json.dumps({
'tag_name': tag,
'name': f'EDID-Viewer v{version}',
'body': notes,
}).encode(), {'Content-Type': 'application/json'})
if status != 201:
sys.exit(f'创建 release 失败: {status} {rel}')
print(f'release 已创建(id={rel["id"]})')
api.upload_asset(rel['id'], args.zipfile_path, args.replace)
print(f'完成: {rel.get("html_url", "") or f"{base}/{owner}/{repo}/releases/tag/{tag}"}')
if __name__ == '__main__':
main()
+50
View File
@@ -0,0 +1,50 @@
import random
import unittest
from edid import parse,dtd
def checksum(b):
b[127]=(-sum(b[:127]))&255
return bytes(b)
def base(ext=0):
b=bytearray(128); b[:8]=bytes.fromhex('00ffffffffffff00'); b[8:10]=bytes.fromhex('4c2d')
b[18:20]=bytes((1,4)); b[20]=0xa2; b[38:54]=b'\x01\x01'*8; b[126]=ext
b[54:72]=bytes.fromhex('023a801871382d40582c4500fd1e1100001e')
b[72:90]=b'\0\0\0\xfc\0TEST PANEL\n '
return bytearray(checksum(b))
class Tests(unittest.TestCase):
def test_identity_and_dtd(self):
r=parse(base()); self.assertEqual(r['基本信息']['显示器名称'],'TEST PANEL')
self.assertEqual(r['基本信息']['厂家 PNP 代码'],'SAM')
t=r['时序'][0]; self.assertEqual(t['分辨率'],'1920×1080p'); self.assertEqual(t['刷新率'],'60.000 Hz')
self.assertEqual(t['同步'],'H+ V+'); self.assertEqual(t['水平有效/前肩/同步/后肩'],[1920,88,44,148])
def test_standard(self):
b=base(); b[38:40]=bytes((129,192)); r=parse(checksum(b))
self.assertEqual(r['时序'][0]['分辨率'],'1280×720'); self.assertEqual(r['时序'][0]['刷新率'],'60 Hz')
def test_cta(self):
b=bytearray(128); payload=bytes.fromhex('429061 23090707 67030c0012343078 e3060c01')
b[:4]=bytes((2,3,4+len(payload),0x70)); b[4:4+len(payload)]=payload
r=parse(bytes(base(1))+checksum(b)); text=str(r)
self.assertIn('1.2.3.4',text); self.assertIn('PQ / ST 2084',text); self.assertIn('3840×2160p 60 Hz',text)
self.assertIn('600 MHz',text); self.assertIn('LPCM',text)
def test_missing_extension(self):
self.assertTrue(parse(base(2))['警告'])
def test_bad_checksum_and_header(self):
b=base(); b[0]=1; r=parse(b); self.assertTrue(r['警告']); self.assertIn('失败',r['校验'][0])
def test_bounds(self):
with self.assertRaises(ValueError): parse(b'\0'*127)
b=bytearray(128); b[:5]=bytes((2,3,6,0,0x5f))
self.assertIn('越界',str(parse(bytes(base(1))+checksum(b))['警告']))
def test_unknown_extension(self):
b=bytearray(128); b[0]=0x70
self.assertIn('暂未解码',str(parse(bytes(base(1))+checksum(b))))
def test_interlaced(self):
b=bytes.fromhex('011d8018711c1620582c2500fd1e1100009e')
t=dtd(b,'test'); self.assertEqual(t['分辨率'],'1920×1080i'); self.assertEqual(t['刷新率'],'60.000 Hz(场率)')
def test_random_malformed(self):
rng=random.Random(42)
for _ in range(1000):
b=bytearray(rng.randbytes(rng.randint(128,600))); b[18]=1; parse(b)
if __name__=='__main__': unittest.main()
+129
View File
@@ -0,0 +1,129 @@
"""Theme persistence and Fluent Widgets integration."""
import ctypes
import os
from PySide6.QtCore import QObject, QSettings, Qt
from PySide6.QtGui import QColor, QPalette
from qfluentwidgets import Theme, setTheme, setThemeColor
# Fluent widgets restyle themselves on setTheme(); QMainWindow, the native
# QTabWidget/QStatusBar and the Windows title bar keep the OS palette unless
# the palette, an app stylesheet and the DWM title bar attributes follow suit
# (same approach as DLPilot app_fluent.py).
_SURFACES = {
False: {"window": "#f4f6f8", "base": "#ffffff", "text": "#1f2328",
"dim": "#57606a", "border": "#d8dee4", "selected_text": "#ffffff"},
True: {"window": "#202020", "base": "#2b2b2b", "text": "#f5f5f5",
"dim": "#9a9a9a", "border": "#3a3a3a", "selected_text": "#1f2328"},
}
class ThemeManager(QObject):
"""Keep the appearance choice persistent and synchronized with Qt."""
MODES = {"system", "light", "dark", "dark1", "dark2"}
_THEMES = {
"system": Theme.AUTO,
"light": Theme.LIGHT,
"dark": Theme.DARK,
"dark1": Theme.DARK,
"dark2": Theme.DARK,
}
_ACCENTS = {
"light": "#0f6cbd",
"dark": "#f2c94c",
"dark1": "#5aa9e6",
"dark2": "#35c6c8",
}
def __init__(self, app):
super().__init__(app)
self.app = app
self.window = None
self.settings = QSettings("LT8619C-Tools", "EDID-Viewer")
mode = self.settings.value("appearance/theme", "system")
self.mode = mode if mode in self.MODES else "system"
self.apply()
app.styleHints().colorSchemeChanged.connect(self.apply)
def set_window(self, window):
"""Register the main window so the native title bar follows the theme."""
self.window = window
self._apply_titlebar()
def set_mode(self, mode):
if mode not in self.MODES:
raise ValueError(f"Unknown theme: {mode}")
self.mode = mode
self.settings.setValue("appearance/theme", mode)
self.apply()
def is_dark(self):
if self.mode in {"dark", "dark1", "dark2"}:
return True
return self.mode == "system" and self.app.styleHints().colorScheme() == Qt.ColorScheme.Dark
def apply(self, *_):
is_dark = self.is_dark()
setTheme(self._THEMES[self.mode])
setThemeColor(self._ACCENTS.get(self.mode, self._ACCENTS["light"]))
self._apply_palette(is_dark)
self._apply_titlebar()
def _apply_palette(self, is_dark):
"""Give native Qt surfaces the same colors as the Fluent widgets."""
surface = _SURFACES[is_dark]
accent = QColor(self._ACCENTS.get(self.mode, self._ACCENTS["light"]))
palette = QPalette()
for role, color in (
(QPalette.Window, surface["window"]),
(QPalette.Base, surface["base"]),
(QPalette.AlternateBase, surface["base"]),
(QPalette.Text, surface["text"]),
(QPalette.WindowText, surface["text"]),
(QPalette.Button, surface["base"]),
(QPalette.ButtonText, surface["text"]),
(QPalette.PlaceholderText, surface["dim"]),
(QPalette.Highlight, accent),
(QPalette.HighlightedText, surface["selected_text"]),
(QPalette.ToolTipBase, surface["base"]),
(QPalette.ToolTipText, surface["text"]),
):
palette.setColor(role, QColor(color))
self.app.setPalette(palette)
self.app.setStyleSheet(f"""
QMainWindow, QWidget#root {{ background-color: {surface['window']}; color: {surface['text']}; }}
QTabWidget::pane {{ background: {surface['base']}; border: 1px solid {surface['border']}; border-radius: 6px; }}
QTabBar {{ qproperty-drawBase: 0; }}
QTabBar::tab {{ background: transparent; color: {surface['dim']}; padding: 6px 16px;
border: 1px solid transparent; border-top-left-radius: 6px;
border-top-right-radius: 6px; }}
QTabBar::tab:selected {{ background: {surface['base']}; color: {surface['text']};
border: 1px solid {surface['border']}; }}
QTabBar::tab:hover:!selected {{ background: {surface['base']}; }}
QSplitter::handle {{ background: {surface['window']}; }}
QStatusBar {{ background: {surface['window']}; color: {surface['dim']};
border-top: 1px solid {surface['border']}; }}
QToolTip {{ background-color: {surface['base']}; color: {surface['text']};
border: 1px solid {surface['border']}; }}
""")
def _apply_titlebar(self):
"""Ask DWM for a title bar in the current surface color (Win10/11)."""
if self.window is None or os.name != "nt" or not hasattr(ctypes, "windll"):
return
try:
hwnd = int(self.window.winId())
dwm = ctypes.windll.dwmapi
dark = ctypes.c_int(1 if self.is_dark() else 0)
dwm.DwmSetWindowAttribute(hwnd, 20, ctypes.byref(dark), ctypes.sizeof(dark))
surface = _SURFACES[self.is_dark()]
dwm.DwmSetWindowAttribute(hwnd, 35, ctypes.byref(self._colorref(QColor(surface["window"]))), 4)
dwm.DwmSetWindowAttribute(hwnd, 36, ctypes.byref(self._colorref(QColor(surface["text"]))), 4)
except Exception:
pass
@staticmethod
def _colorref(color):
return ctypes.c_int(color.red() | (color.green() << 8) | (color.blue() << 16))
+44
View File
@@ -0,0 +1,44 @@
"""Read active monitors through Windows WMI; never change display settings."""
import json
import subprocess
SCRIPT = r'''
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
$connections = @{}
try { Get-CimInstance -Namespace root/wmi -ClassName WmiMonitorConnectionParams | ForEach-Object { $connections[$_.InstanceName] = [long]$_.VideoOutputTechnology } } catch {}
$result = @(Get-CimInstance -Namespace root/wmi -ClassName WmiMonitorDescriptorMethods | Where-Object Active | ForEach-Object {
$m = $_
$bytes = [System.Collections.Generic.List[byte]]::new()
$failure = ''
try {
$base = Invoke-CimMethod -InputObject $m -MethodName WmiGetMonitorRawEEdidV1Block -Arguments @{BlockId=[byte]0}
if (-not $base.ReturnValue -or $base.BlockContent.Count -ne 128) { throw 'Base EDID read failed' }
$bytes.AddRange([byte[]]$base.BlockContent)
for ($i=1; $i -le [int]$base.BlockContent[126]; $i++) {
$ext = Invoke-CimMethod -InputObject $m -MethodName WmiGetMonitorRawEEdidV1Block -Arguments @{BlockId=[byte]$i}
if (-not $ext.ReturnValue -or $ext.BlockContent.Count -ne 128) { throw "Extension $i read failed" }
$bytes.AddRange([byte[]]$ext.BlockContent)
}
} catch { $failure = $_.Exception.Message }
[pscustomobject]@{instance=$m.InstanceName; connection=$connections[$m.InstanceName]; hex=([BitConverter]::ToString($bytes.ToArray())).Replace('-',''); error=$failure}
})
ConvertTo-Json -InputObject $result -Depth 4 -Compress
'''
CONNECTIONS = {0:'VGA',4:'DVI',5:'HDMI',6:'LVDS 内屏',10:'DisplayPort',11:'eDP 内屏',15:'Miracast',16:'间接有线显示',2147483648:'内部显示'}
def scan():
import base64
command = base64.b64encode(SCRIPT.encode('utf-16le')).decode('ascii')
proc = subprocess.run(['powershell.exe','-NoProfile','-NonInteractive','-EncodedCommand',command],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=45,
creationflags=subprocess.CREATE_NO_WINDOW)
if proc.returncode:
raise RuntimeError(proc.stderr.decode('utf-8','replace').strip())
data = json.loads(proc.stdout.decode('utf-8-sig'))
for item in data:
item['raw'] = bytes.fromhex(item.pop('hex'))
item['连接'] = CONNECTIONS.get(item['connection'],f'未知 ({item["connection"]})')
return data