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
+1
View File
@@ -9,6 +9,7 @@ dist/
# 发布 zip: 可由 src/package_release.py 重新生成, 只上传 Gitea Release # 发布 zip: 可由 src/package_release.py 重新生成, 只上传 Gitea Release
DLPilot/release/*.zip DLPilot/release/*.zip
EDID-viewer/release/*.zip
# 源码模式运行时的配置写入(config.BASE_DIR=src/), 与 release/ 下入库的预置栏位区分 # 源码模式运行时的配置写入(config.BASE_DIR=src/), 与 release/ 下入库的预置栏位区分
DLPilot/src/pattern_slots.json DLPilot/src/pattern_slots.json
+8
View File
@@ -0,0 +1,8 @@
# 本机虚拟环境(根 .gitignore 已覆盖 build/ dist/ *.spec __pycache__/)
.venv/
# 构建日志(测试结果由 unittest 实时输出, 不留档)
build-log*.txt
# 验证脚本生成的截图
validation/*.png
+44
View File
@@ -0,0 +1,44 @@
# EDID Viewer — 环境与任务声明
## 基本环境
- 主项目位置:`E:\Hulk-Coding\LT8619C_Debug_Tools\EDID-viewer`;为兼容此前约定,已同步镜像到 `E:\Hulk-Coding\LT8619C\_Debug\_Tools\EDID-viewer`。
- Windows 10/11 x64;Python 3.14;PySide6 6.11.2(Qt 官方 Python 绑定);PySide6-Fluent-Widgets 1.11.3。
- 使用项目 `.venv`;依赖见 requirements.txt;GUI 入口为 `src/main.py`(版本号在 `src/main.py` 的 `APP_VERSION`)。
- 独立工具,不依赖、不修改 LT8619C 固件或 DLPilot 工程。
## 工程结构与流水线(对齐 DLPilot 三区约定)
- 三区目录:`src/` 人写代码(main / edid / windows_edid / theme / test_edid + 打包流水线);`assets/` 图标与生成脚本(可再生);`release/` 交付(`EDID-Viewer.exe` 入库 + README)。
- 打包单文件 exe:`python src/build_exe.py`(spec 由脚本生成到 `src/build/`,不入库;原 viewer.spec 的 ICU 排除逻辑已内置其中)。`--debug` 附带带控制台变体。
- 发布:`python src/package_release.py <版本>` 组装 `release/EDID-Viewer-v*-win64.zip`(zip 不入库,只上传 Gitea Release);上传用 `python src/publish_gitea.py <zip>`,token 走环境变量 `GITEA_TOKEN`。
- 解析测试:`cd src && python -m unittest`(9 项,改 edid.py 后必跑)。
## 任务目标
1. 枚举 Windows 活动显示设备并读取 EDID 原始块。
2. 查看厂家代码、产品编号、名称、序列号、生产日期、分辨率和详细时序。
3. 展示 CTA 扩展、HDMI、音频、HDR、色彩能力和校验结果。
4. 支持离线 BIN/HEX 导入、BIN 和 JSON 报告导出。
5. Light / Dark / 跟随系统主题,持久化用户选择并响应系统主题变化。
6. 图标参考 DLPilot 黑底、橙色标签、白字,使用 EDID / Viewer 独立标识。
- Fluent 主题包含 `dark1`(蓝色)和 `dark2`(青色),两者均为显式深色模式;选中态文字必须保持高对比度。
- Fluent Widgets 版本已采用顶部操作区、下方信息卡片和分组页签;后续布局调整需保持信息层级清晰。
## Fluent Widgets 版本
- 界面使用 PySide6-Fluent-Widgets 的 `CardWidget`、`ComboBox`、`PrimaryPushButton`、`PlainTextEdit` 和 `TreeWidget`;顶部操作区、下方信息卡片和分组页签。
- Fluent 主题只负责表现层;EDID 解析、Windows WMI 读取、导入导出和线程边界不改变。
- 主题切换必须三件套同步(与 DLPilot `app_fluent.py` 同一方案):`setTheme` 之外,QPalette + 应用级 QSS 统一 QMainWindow 背景、原生 QTabWidget/QStatusBar 表面色,DWM 属性 20/35/36 切换原生标题栏深浅。只调 `setTheme` 会导致 Light 模式下窗口背景、页签和标题栏仍是系统深色。
- 窗口截图验证可用 `validation\capture-window.ps1`(按进程窗口矩形截屏,注意全屏窗口会遮挡)。
## 实现与边界
- 只读 Windows WMI 显示驱动提供的 EDID;不写 EDID、不更改分辨率或显示拓扑。
- Windows/驱动可能缓存或覆盖 EDID;不能声称数据是实时 DDC 总线抓取。
- HDMI、DP、内屏均列出,连接类型来自 WmiMonitorConnectionParams,未知则如实标注。
- 无法读取的设备、缺失扩展、未知数据块和无效校验必须明确展示,不猜测。
- EDID 表示接收端声明能力,不能证明当前链路色深、HDR、HDCP 或真实带宽。
- 解析器与 UI、Windows 读取分离;读取在后台线程完成。
- 添加解析边界/错误数据测试。打包后验证实际可见窗口、缩放、刷新和导入导出流程。
- 第一版不完整解码 DisplayID、HDMI Forum VRR/DSC;保留原始块供分析。
## 后续建议
- EDID 对比、字节差异与固件数组导出。
- HDMI Forum VRR/ALLM/FRL/DSC、DisplayID 与高级色彩数据解析。
- 与实际输出时序和桥接芯片寄存器联合诊断。
+65
View File
@@ -0,0 +1,65 @@
# EDID Viewer
Windows 10/11 显示器 EDID 查看器。使用 **PySide6 / PySide6-Fluent-Widgets**,只读,不修改显示设置。
> 只想使用工具:到 [Gitea Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases)
> 下载 `EDID-Viewer-vX.Y.Z-win64.zip`,解压双击 `EDID-Viewer.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
## 使用
- 双击 `release\EDID-Viewer.exe`(单文件,免安装)。
- 或双击 `start.cmd` 使用本机 Python 环境运行源码。
- 右上角「外观」选择 Light(浅色)、Dark(琥珀深色)、dark1(蓝色深色)、dark2(青色深色)或跟随系统;默认跟随系统,选择自动保存,系统模式响应 Windows 明暗变化。
- 图标沿用 DLPilot 的黑底/橙色标签/白字语言,文字为 EDID / Viewer;窗口、任务栏和 EXE 使用同一图标。
- 当前布局采用 Fluent Widgets:顶部设备/文件操作卡片、右上角「关于」,下方信息卡片和分组页签;Dark 使用琥珀强调色,dark1 使用蓝色强调色,dark2 使用青色强调色,主题随系统设置持久化。
- 点击「刷新设备」,从下拉框选择设备。HDMI、DP 和内屏均显示连接类型。
- 「设备与能力」显示身份和音频/HDR/色彩/HDMI 信息;「分辨率与时序」可展开 DTD 查看像素时钟、前后肩、同步宽度和极性。
- 「原始 HEX」支持选中复制。「导出 BIN」保持读取到的字节不变;「导出报告」生成含原始数据的 UTF-8 JSON。
- 支持 `.bin/.edid` 二进制和 `.hex/.txt` 纯十六进制。HEX 可以使用空格、逗号、`0x` 前缀,不接受带地址/ASCII 栏的 hexdump。
## 目录结构(对齐 DLPilot)
- `src/` — 人写代码:界面(main.py)、EDID 解析(edid.py)、Windows 读取(windows_edid.py)、主题(theme.py)、解析测试(test_edid.py)和打包流水线。
- `assets/` — 图标与生成脚本(可再生)。
- `release/` — 交付物:`EDID-Viewer.exe` 入库;发布 zip 只上传 Gitea Release,不入库。
- `AGENTS.md` — 工程约定与任务边界(改代码前必读);`VALIDATION.md` — 验证记录。
## 环境与构建
本机:Python 3.14、PySide6 6.11.2、PySide6-Fluent-Widgets 1.11.3、PyInstaller 6.22.2。当前 `.venv` 通过 `--system-site-packages` 复用已安装依赖,不修改全局包。新电脑可以创建独立环境:
```powershell
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
.\.venv\Scripts\python.exe src\main.py
cd src && python -m unittest # 9 项解析测试
python src\build_exe.py # 打包 → release\EDID-Viewer.exe
python src\package_release.py 0.1.0 # 组装 release\EDID-Viewer-v0.1.0-win64.zip
python src\publish_gitea.py ..\release\EDID-Viewer-v0.1.0-win64.zip --insecure
```
打包为单文件 exe;spec 由 `build_exe.py` 生成到 `src/build/`(不入库),其中剔除 PATH 上其他工具(Poppler/Conda)的版本化 ICU DLL,避免 QtCore 加载失败(WinError 127)。构建前关闭正在运行的 EDID Viewer,以免文件被占用。图标源为 `assets/make_icon.py`,重新生成时需安装 Pillow(仅开发时需要)。主题配置保存在当前用户的 `QSettings: LT8619C-Tools/EDID-Viewer`。
## 能力范围
- EDID 1.x 基础身份、生产日期、名称/序列号、物理尺寸、Gamma、色深、色度坐标。
- 已建立/标准时序、基础与 CTA 详细时序;常用 CTA VIC(未映射的 VIC 显示编号)。
- 多个 CTA 扩展、音频 SAD、HDMI VSDB、CEC 地址、深色、TMDS、HF-VSDB 的 SCDC/TMDS。
- HDR EOTF、色彩标准、YCbCr 4:2:0 声明。未知块原样保留。
- 每块校验和、扩展计数、截断和数据块越界诊断。
## 限制
- 读取 `root/wmi:WmiMonitorDescriptorMethods` 中的活动设备,不保证列出禁用输出/驱动不提供 WMI 的设备。失败会显示原因,可改用离线文件。
- 数据来自 Windows/显卡驱动,可能是缓存或覆盖内容,不是实时 DDC 抓取。读取扩展失败时保留已经取得的块并报告缺失。
- EDID 能力不等于当前输出模式;不能据此确定 HDCP、线材质量、正在使用的 HDR/色深或 HDMI 版本。
- DisplayID 和 HDMI Forum VRR/ALLM/FRL/DSC 尚未详细解码;仅包含 DisplayID 时序的屏幕可能没有可解码的分辨率。范围描述符扩展偏移仅提示,未应用。
- 不承诺所有 CTA VIC/扩展标签均已覆盖。厂商首先显示标准 PNP 三字母代码,避免错误猜测品牌。
## 推荐后续挖掘
1. **桥接板时序匹配**:像素时钟、H/V 消隐、同步极性和首选模式,排查黑屏、偏色、画面偏移。
2. **EDID 差异比较**:对照板卡固件、Windows 读取值、显示器原始值,定位缓存、截断或改写。
3. **带宽与色彩**:TMDS/FRL、RGB/YCbCr、深色和 4:2:0,解释分辨率或刷新率受限。
4. **HDR 与音频**:PQ/HLG、亮度元数据、PCM 位深/采样率/声道和压缩音频能力。
5. **DisplayID / VRR / DSC**:适合高刷新率和高分辨率显示设备,作为下一阶段扩展。
+50
View File
@@ -0,0 +1,50 @@
# 验证记录 — 2026-09-23
- Python 3.14 / PySide6 6.11.2 / PyInstaller 6.22.2。
- 9 个解析测试通过,包括身份字段、标准时序、1080p/1080i DTD、CTA 音频/4K/HDR/CEC、缺失扩展、坏校验、越界、未知扩展;其中包括 1000 份随机畸形数据的无崩溃检查。
- BIN → 带 0x 前缀 HEX → BIN 一致性检查通过。
- `pip check` 通过。
- WMI 实际读取到内屏 SDC / ATNA60HU01-0;取得 256 字节,两个块校验通过;声明 384 字节,读取第三块失败。UI 正确展示读取错误和缺失警告。
- 打包 EXE 实际启动通过,QtCore/QtGui/QtWidgets 绑定文件存在。截图检查确认设备信息、中文、校验、警告均可见。
- 修复 PATH 中 Poppler ICU 被 PyInstaller 错误收集导致的 QtCore DLL 加载失败;`viewer.spec` 排除冲突 ICU,使用 Windows 系统 ICU。
- 修复系统深色主题与浅色内容背景造成的白字不可见问题,使用完整浅色 Qt 调色板。
- 当前没有可用于验证的外接 HDMI 设备;HDMI 热插拔、多屏、跨屏 DPI、最大化/恢复和 GUI 文件对话框完整流程尚未完成交互验收。检查期间窗口发生用户输入/位置变化,没有继续抢占窗口操作。
- DisplayID、高级 HDMI Forum 字段未完整解析,详见 README。
`validation/` 保存本机读取样本和合成 HDMI 测试数据,仅作本地验收材料。
## 主题与图标更新
- 新 EXE 实际启动通过,窗口展示 EDID / Viewer 黑橙白图标。
- 实际通过右上角切换 Light、Dark;截图检查文字、数据树、按钮、选择框的对比度正常。
- 跟随系统在当前 Windows 深色设置下正确显示深色。已连接 Qt `colorSchemeChanged`;未修改用户 Windows 设置进行系统热切换测试。
- Dark 选择写入 QSettings,关闭 EXE 后重新启动,选择和深色外观均保留。
- 最大化后界面正常,随后还原;没有进行跨显示器 DPI 验证。
- 9 项解析测试仍通过;三种主题的调色板检查通过。
- qt-material 2.17 已在 PySide6 环境加载;Light/Dark/System 三种模式样式表生成检查通过。
- Material 版窗口使用左侧设备/离线操作面板和右侧内容卡片,源码启动与解析测试通过;需完成本次打包后的可见 EXE 检查。
- 本次打包 EXE 实际启动通过;可见界面已切换为 Material Amber 主题,侧栏、圆角内容卡片、页签、设备数据和警告均正常显示。
- 实机操作验证 Light 与 Dark 切换,文字和数据树对比度正常;Material 版保留系统跟随选项。
- 修复 Material 选中态对比度:设备/主题下拉框选中项改为深色文字配琥珀色背景;树表格、焦点行、页签和警告提示分别使用可读的深色/浅色组合。源码测试和打包 EXE 均已更新。
- 针对截图反馈再次调整页签选中态:深色使用深灰底/琥珀字,浅色使用浅琥珀底/深琥珀字,并保留底部强调线;Light 打包窗口实测“分辨率与时序”页签可读。
- 针对 HEX/JSON 截图反馈调整 `QPlainTextEdit`/`QTextEdit` 文本选中态:深色使用蓝灰底/浅字,浅色使用浅蓝灰底/深字,避免 qt-material 的亮黄色底/白字组合。源码样式检查和解析测试通过;本次重新打包后窗口启动验证因物理 Escape 中断,未继续进行 UI 操作。
- 主题选择框和下拉列表已加宽至 148/至少 170 px,三种中文选项可完整显示;控件尺寸检查通过,EXE 已重新打包。
# 验证记录 — 2026-09-24
- 新增 `dark1`(`dark_blue.xml`,蓝色强调)和 `dark2`(`dark_cyan.xml`,青色强调)两个深色 Material 主题;原有 Light、Dark、跟随系统仍保留。
- 9 项解析测试、`pip check` 和 Python 编译检查通过;qt-material 2.17 已确认包含两个新主题资源。
- 最新 `dist\EDID-Viewer\EDID-Viewer.exe` 已重新构建并启动,进程响应正常;offscreen 窗口渲染确认两个主题的主题选择框、页签和内容面板均能显示。
- 深色主题的下拉选中项、树表格焦点行、页签、警告和 HEX/JSON 文本选择态均使用按主题的高对比配色,避免亮背景配白字。
- 当前预览使用无 EDID 样本的启动状态;Windows WMI 实际设备读取结果沿用上一条记录,未把本次 offscreen 渲染当作硬件验收。
# Fluent Widgets 重写验证(2026-09-24)
- `PySide6-Fluent-Widgets 1.11.3` 已替换 qt-material;主界面使用 Fluent 卡片、控件和树视图,主题选择持久化并支持跟随系统。
- 9 项 EDID 解析测试、Python 编译检查和离屏窗口启动通过;主题下拉框可切换 system/light/dark/dark1/dark2。
- `build.ps1` 打包通过,`dist\\EDID-Viewer\\_internal\\qfluentwidgets` 资源已收集;离线 EDID 样本可渲染并启用 BIN/JSON 导出。
# 目录重构与 onefile 打包验证(2026-09-24)
- 目录对齐 DLPilot 三区结构:`src/`(代码 + 流水线)、`assets/`(图标)、`release/`(EDID-Viewer.exe + README);`agent.md` 更名 `AGENTS.md`;`build.ps1`/`viewer.spec` 由 `src/build_exe.py`(生成 spec,ICU 排除逻辑内置)取代。
- `src/` 下 9 项解析测试通过;`src/build_exe.py` 单文件打包通过,`release\EDID-Viewer.exe` 启动验证窗口正常、图标与主题(三件套:QPalette + 应用级 QSS + DWM 标题栏)保持上一轮修复效果。
- `src/package_release.py` 组装 zip(exe + README);`src/publish_gitea.py` 由 DLPilot 同名脚本适配(tag 前缀 `EDID-Viewer-v*`),本次未实际上传。
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

+13
View File
@@ -0,0 +1,13 @@
"""Rebuild icon with Pillow (development only); matches the DLPilot brand."""
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
out = Path(__file__).resolve().parent
image = Image.new('RGB', (512,512), 'black')
draw = ImageDraw.Draw(image)
draw.rounded_rectangle((38,64,474,250),radius=34,fill='#ff9900')
font = 'C:/Windows/Fonts/arialbd.ttf'
draw.text((256,157),'EDID',font=ImageFont.truetype(font,134),anchor='mm',fill='black')
draw.text((256,368),'Viewer',font=ImageFont.truetype(font,116),anchor='mm',fill='white')
image.save(out/'edid-viewer.png')
image.save(out/'edid-viewer.ico',sizes=[(16,16),(24,24),(32,32),(48,48),(64,64),(128,128),(256,256)])
Binary file not shown.
+65
View File
@@ -0,0 +1,65 @@
# EDID Viewer
Windows 10/11 显示器 EDID 查看器。使用 **PySide6 / PySide6-Fluent-Widgets**,只读,不修改显示设置。
> 只想使用工具:到 [Gitea Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases)
> 下载 `EDID-Viewer-vX.Y.Z-win64.zip`,解压双击 `EDID-Viewer.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
## 使用
- 双击 `release\EDID-Viewer.exe`(单文件,免安装)。
- 或双击 `start.cmd` 使用本机 Python 环境运行源码。
- 右上角「外观」选择 Light(浅色)、Dark(琥珀深色)、dark1(蓝色深色)、dark2(青色深色)或跟随系统;默认跟随系统,选择自动保存,系统模式响应 Windows 明暗变化。
- 图标沿用 DLPilot 的黑底/橙色标签/白字语言,文字为 EDID / Viewer;窗口、任务栏和 EXE 使用同一图标。
- 当前布局采用 Fluent Widgets:顶部设备/文件操作卡片、右上角「关于」,下方信息卡片和分组页签;Dark 使用琥珀强调色,dark1 使用蓝色强调色,dark2 使用青色强调色,主题随系统设置持久化。
- 点击「刷新设备」,从下拉框选择设备。HDMI、DP 和内屏均显示连接类型。
- 「设备与能力」显示身份和音频/HDR/色彩/HDMI 信息;「分辨率与时序」可展开 DTD 查看像素时钟、前后肩、同步宽度和极性。
- 「原始 HEX」支持选中复制。「导出 BIN」保持读取到的字节不变;「导出报告」生成含原始数据的 UTF-8 JSON。
- 支持 `.bin/.edid` 二进制和 `.hex/.txt` 纯十六进制。HEX 可以使用空格、逗号、`0x` 前缀,不接受带地址/ASCII 栏的 hexdump。
## 目录结构(对齐 DLPilot)
- `src/` — 人写代码:界面(main.py)、EDID 解析(edid.py)、Windows 读取(windows_edid.py)、主题(theme.py)、解析测试(test_edid.py)和打包流水线。
- `assets/` — 图标与生成脚本(可再生)。
- `release/` — 交付物:`EDID-Viewer.exe` 入库;发布 zip 只上传 Gitea Release,不入库。
- `AGENTS.md` — 工程约定与任务边界(改代码前必读);`VALIDATION.md` — 验证记录。
## 环境与构建
本机:Python 3.14、PySide6 6.11.2、PySide6-Fluent-Widgets 1.11.3、PyInstaller 6.22.2。当前 `.venv` 通过 `--system-site-packages` 复用已安装依赖,不修改全局包。新电脑可以创建独立环境:
```powershell
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
.\.venv\Scripts\python.exe src\main.py
cd src && python -m unittest # 9 项解析测试
python src\build_exe.py # 打包 → release\EDID-Viewer.exe
python src\package_release.py 0.1.0 # 组装 release\EDID-Viewer-v0.1.0-win64.zip
python src\publish_gitea.py ..\release\EDID-Viewer-v0.1.0-win64.zip --insecure
```
打包为单文件 exe;spec 由 `build_exe.py` 生成到 `src/build/`(不入库),其中剔除 PATH 上其他工具(Poppler/Conda)的版本化 ICU DLL,避免 QtCore 加载失败(WinError 127)。构建前关闭正在运行的 EDID Viewer,以免文件被占用。图标源为 `assets/make_icon.py`,重新生成时需安装 Pillow(仅开发时需要)。主题配置保存在当前用户的 `QSettings: LT8619C-Tools/EDID-Viewer`。
## 能力范围
- EDID 1.x 基础身份、生产日期、名称/序列号、物理尺寸、Gamma、色深、色度坐标。
- 已建立/标准时序、基础与 CTA 详细时序;常用 CTA VIC(未映射的 VIC 显示编号)。
- 多个 CTA 扩展、音频 SAD、HDMI VSDB、CEC 地址、深色、TMDS、HF-VSDB 的 SCDC/TMDS。
- HDR EOTF、色彩标准、YCbCr 4:2:0 声明。未知块原样保留。
- 每块校验和、扩展计数、截断和数据块越界诊断。
## 限制
- 读取 `root/wmi:WmiMonitorDescriptorMethods` 中的活动设备,不保证列出禁用输出/驱动不提供 WMI 的设备。失败会显示原因,可改用离线文件。
- 数据来自 Windows/显卡驱动,可能是缓存或覆盖内容,不是实时 DDC 抓取。读取扩展失败时保留已经取得的块并报告缺失。
- EDID 能力不等于当前输出模式;不能据此确定 HDCP、线材质量、正在使用的 HDR/色深或 HDMI 版本。
- DisplayID 和 HDMI Forum VRR/ALLM/FRL/DSC 尚未详细解码;仅包含 DisplayID 时序的屏幕可能没有可解码的分辨率。范围描述符扩展偏移仅提示,未应用。
- 不承诺所有 CTA VIC/扩展标签均已覆盖。厂商首先显示标准 PNP 三字母代码,避免错误猜测品牌。
## 推荐后续挖掘
1. **桥接板时序匹配**:像素时钟、H/V 消隐、同步极性和首选模式,排查黑屏、偏色、画面偏移。
2. **EDID 差异比较**:对照板卡固件、Windows 读取值、显示器原始值,定位缓存、截断或改写。
3. **带宽与色彩**:TMDS/FRL、RGB/YCbCr、深色和 4:2:0,解释分辨率或刷新率受限。
4. **HDR 与音频**:PQ/HLG、亮度元数据、PCM 位深/采样率/声道和压缩音频能力。
5. **DisplayID / VRR / DSC**:适合高刷新率和高分辨率显示设备,作为下一阶段扩展。
+4
View File
@@ -0,0 +1,4 @@
PySide6==6.11.2
PySide6-Fluent-Widgets==1.11.3
pyinstaller==6.22.2
+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
+3
View File
@@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0"
start "" ".venv\Scripts\pythonw.exe" "src\main.py"
+48
View File
@@ -0,0 +1,48 @@
"""Headless check for the About button and dialog: saves header and dialog PNGs."""
import sys
from pathlib import Path
from PySide6.QtCore import QTimer
from PySide6.QtGui import QFont
from PySide6.QtWidgets import QApplication, QMessageBox
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
import main as m
OUT = Path(__file__).resolve().parent
def snap_dialog():
boxes = [w for w in QApplication.topLevelWidgets() if isinstance(w, QMessageBox)]
if not boxes:
QTimer.singleShot(200, snap_dialog)
return
boxes[0].grab().save(str(OUT / "about-dialog.png"))
boxes[0].close()
def open_about(window):
QTimer.singleShot(600, lambda: snap_dialog())
window.show_about()
def main():
app = QApplication(sys.argv)
app.setApplicationName(m.APP_NAME)
app.setFont(QFont("Microsoft YaHei UI", 10))
theme_manager = m.ThemeManager(app)
window = m.Window(theme_manager)
window.show()
def grab_header():
window.grab().save(str(OUT / "about-header.png"))
open_about(window)
QTimer.singleShot(1500, grab_header)
QTimer.singleShot(6000, app.quit)
sys.exit(app.exec())
if __name__ == "__main__":
main()
+30
View File
@@ -0,0 +1,30 @@
param(
[string]$ProcessName = "python",
[string]$OutPng = "$env:TEMP\edid-window.png"
)
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class Win32Cap {
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
public struct RECT { public int Left, Top, Right, Bottom; }
}
"@
Add-Type -AssemblyName System.Drawing
$proc = Get-Process -Name $ProcessName -ErrorAction Stop |
Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like 'EDID*' } |
Select-Object -First 1
if (-not $proc) { throw "no EDID window found for process '$ProcessName'" }
$hwnd = $proc.MainWindowHandle
[Win32Cap]::SetForegroundWindow($hwnd) | Out-Null
Start-Sleep -Milliseconds 800
$r = New-Object Win32Cap+RECT
[Win32Cap]::GetWindowRect($hwnd, [ref]$r) | Out-Null
$w = $r.Right - $r.Left; $h2 = $r.Bottom - $r.Top
$bmp = New-Object System.Drawing.Bitmap($w, $h2)
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.CopyFromScreen($r.Left, $r.Top, 0, 0, (New-Object System.Drawing.Size($w, $h2)))
$bmp.Save($OutPng, [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
Write-Output "saved $OutPng ($w x $h2)"
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
{
"instance": "DISPLAY\\SDC420B\\4&1387f127&0&UID8388688_0",
"connection": 2147483648,
"error": "常规故障 ",
"连接": "内部显示",
"基本信息": {
"厂家 PNP 代码": "SDC",
"产品编号": "0x420B",
"数字序列号": 0,
"EDID 版本": "1.4",
"生产日期": "2024 年 / 周未指定",
"尺寸": "34 × 22 cm",
"Gamma": 2.2,
"扩展块数": 2,
"SHA-256": "16f168eda782b8d49c3ef5cdf46cb894c353c6ef2570629707b72a0cdefb9584",
"输入类型": "数字",
"接口声明": "DisplayPort",
"色深 bpc": 10,
"频率范围原始值": "垂直 48–240 Hz;水平 177–177 kHz;最大像素时钟 1170 MHz",
"描述文本": "SDC",
"显示器名称": "ATNA60HU01-0"
},
"时序": [],
"能力": [
"色度坐标:Rx=0.6807, Ry=0.3184, Gx=0.2197, Gy=0.7246, Bx=0.1426, By=0.0391, Wx=0.3135, Wy=0.3291"
],
"数据块": [
{
"块": 1,
"类型": "DisplayID(暂未解码)",
"原始": "70 20 79 02 00 20 00 0c ba 41 59 0b 42 00 00 00 00 00 18 00 21 00 1d 74 0d 69 08 00 0a 40 06 00 e5 7a 51 85 a3 b9 4a 02 0a 02 45 54 d0 5f 4c 64 00 45 23 78 26 00 09 06 00 00 00 00 00 50 00 00 22 00 28 80 c5 11 85 ff 09 87 00 07 00 1f 00 3f 06 c7 00 7f 00 07 00 80 c5 11 05 ff 09 87 00 07 00 1f 00 3f 06 df 15 97 15 07 00 81 00 0b e3 05 80 00 e6 06 05 01 8f 6a 02 00 00 00 00 00 f1 90"
}
],
"校验": [
"块 0: 通过",
"块 1: 通过"
],
"警告": [
"声明 384 字节,实际 256 字节",
"范围描述符有扩展偏移,频率范围显示原始字段,暂未应用偏移"
]
}
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x4C, 0x2D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x04, 0xA2, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x3A, 0x80, 0x18, 0x71, 0x38, 0x2D, 0x40, 0x58, 0x2C, 0x45, 0x00, 0xFD, 0x1E, 0x11, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0xFC, 0x00, 0x54, 0x45, 0x53, 0x54, 0x20, 0x50, 0x41, 0x4E, 0x45, 0x4C, 0x0A, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xC2, 0x02, 0x03, 0x17, 0x70, 0x42, 0x90, 0x61, 0x23, 0x09, 0x07, 0x07, 0x67, 0x03, 0x0C, 0x00, 0x12, 0x34, 0x30, 0x78, 0xE3, 0x06, 0x0C, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xAD