392 lines
14 KiB
Python
392 lines
14 KiB
Python
"""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/HW-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())
|