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,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())
|
||||
Reference in New Issue
Block a user