From fe4bca5a337e4df3729de60cf589902eb9a216e4 Mon Sep 17 00:00:00 2001 From: hulk Date: Thu, 24 Sep 2026 22:34:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(EDID-Viewer):=20v0.1.0=20=E5=85=A5?= =?UTF-8?q?=E5=BA=93=20=E2=80=94=20Fluent=20EDID=20=E6=9F=A5=E7=9C=8B?= =?UTF-8?q?=E5=99=A8=EF=BC=8C=E7=9B=AE=E5=BD=95=E5=AF=B9=E9=BD=90=20DLPilo?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 本机/合成样本与窗口截图、关于对话框自检脚本 --- .gitignore | 1 + EDID-viewer/.gitignore | 8 + EDID-viewer/AGENTS.md | 44 +++ EDID-viewer/README.md | 65 ++++ EDID-viewer/VALIDATION.md | 50 +++ EDID-viewer/assets/edid-viewer.ico | Bin 0 -> 23251 bytes EDID-viewer/assets/edid-viewer.png | Bin 0 -> 9399 bytes EDID-viewer/assets/make_icon.py | 13 + EDID-viewer/release/README.md | 65 ++++ EDID-viewer/requirements.txt | 4 + EDID-viewer/src/build_exe.py | 108 ++++++ EDID-viewer/src/edid.py | 157 +++++++++ EDID-viewer/src/main.py | 391 ++++++++++++++++++++++ EDID-viewer/src/package_release.py | 61 ++++ EDID-viewer/src/publish_gitea.py | 154 +++++++++ EDID-viewer/src/test_edid.py | 50 +++ EDID-viewer/src/theme.py | 129 +++++++ EDID-viewer/src/windows_edid.py | 44 +++ EDID-viewer/start.cmd | 3 + EDID-viewer/validation/about_check.py | 48 +++ EDID-viewer/validation/capture-window.ps1 | 30 ++ EDID-viewer/validation/live-0.bin | Bin 0 -> 256 bytes EDID-viewer/validation/live-0.json | 42 +++ EDID-viewer/validation/sample-hdmi.bin | Bin 0 -> 256 bytes EDID-viewer/validation/sample-hdmi.hex | 1 + 25 files changed, 1468 insertions(+) create mode 100644 EDID-viewer/.gitignore create mode 100644 EDID-viewer/AGENTS.md create mode 100644 EDID-viewer/README.md create mode 100644 EDID-viewer/VALIDATION.md create mode 100644 EDID-viewer/assets/edid-viewer.ico create mode 100644 EDID-viewer/assets/edid-viewer.png create mode 100644 EDID-viewer/assets/make_icon.py create mode 100644 EDID-viewer/release/README.md create mode 100644 EDID-viewer/requirements.txt create mode 100644 EDID-viewer/src/build_exe.py create mode 100644 EDID-viewer/src/edid.py create mode 100644 EDID-viewer/src/main.py create mode 100644 EDID-viewer/src/package_release.py create mode 100644 EDID-viewer/src/publish_gitea.py create mode 100644 EDID-viewer/src/test_edid.py create mode 100644 EDID-viewer/src/theme.py create mode 100644 EDID-viewer/src/windows_edid.py create mode 100644 EDID-viewer/start.cmd create mode 100644 EDID-viewer/validation/about_check.py create mode 100644 EDID-viewer/validation/capture-window.ps1 create mode 100644 EDID-viewer/validation/live-0.bin create mode 100644 EDID-viewer/validation/live-0.json create mode 100644 EDID-viewer/validation/sample-hdmi.bin create mode 100644 EDID-viewer/validation/sample-hdmi.hex diff --git a/.gitignore b/.gitignore index 74d84a2..11a98d3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ dist/ # 发布 zip: 可由 src/package_release.py 重新生成, 只上传 Gitea Release DLPilot/release/*.zip +EDID-viewer/release/*.zip # 源码模式运行时的配置写入(config.BASE_DIR=src/), 与 release/ 下入库的预置栏位区分 DLPilot/src/pattern_slots.json diff --git a/EDID-viewer/.gitignore b/EDID-viewer/.gitignore new file mode 100644 index 0000000..de82a01 --- /dev/null +++ b/EDID-viewer/.gitignore @@ -0,0 +1,8 @@ +# 本机虚拟环境(根 .gitignore 已覆盖 build/ dist/ *.spec __pycache__/) +.venv/ + +# 构建日志(测试结果由 unittest 实时输出, 不留档) +build-log*.txt + +# 验证脚本生成的截图 +validation/*.png diff --git a/EDID-viewer/AGENTS.md b/EDID-viewer/AGENTS.md new file mode 100644 index 0000000..2572b7b --- /dev/null +++ b/EDID-viewer/AGENTS.md @@ -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 `,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 与高级色彩数据解析。 +- 与实际输出时序和桥接芯片寄存器联合诊断。 diff --git a/EDID-viewer/README.md b/EDID-viewer/README.md new file mode 100644 index 0000000..3fbe343 --- /dev/null +++ b/EDID-viewer/README.md @@ -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**:适合高刷新率和高分辨率显示设备,作为下一阶段扩展。 diff --git a/EDID-viewer/VALIDATION.md b/EDID-viewer/VALIDATION.md new file mode 100644 index 0000000..f4fec10 --- /dev/null +++ b/EDID-viewer/VALIDATION.md @@ -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*`),本次未实际上传。 diff --git a/EDID-viewer/assets/edid-viewer.ico b/EDID-viewer/assets/edid-viewer.ico new file mode 100644 index 0000000000000000000000000000000000000000..cdac5efacd79421ba0e6978cb642a473d12514ad GIT binary patch literal 23251 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 literal 0 HcmV?d00001 diff --git a/EDID-viewer/assets/edid-viewer.png b/EDID-viewer/assets/edid-viewer.png new file mode 100644 index 0000000000000000000000000000000000000000..2bb450bdbfad3a53ef0206736f7b1f97804a2aa9 GIT binary patch literal 9399 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 只想使用工具:到 [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**:适合高刷新率和高分辨率显示设备,作为下一阶段扩展。 diff --git a/EDID-viewer/requirements.txt b/EDID-viewer/requirements.txt new file mode 100644 index 0000000..5ee4c5f --- /dev/null +++ b/EDID-viewer/requirements.txt @@ -0,0 +1,4 @@ +PySide6==6.11.2 +PySide6-Fluent-Widgets==1.11.3 +pyinstaller==6.22.2 + diff --git a/EDID-viewer/src/build_exe.py b/EDID-viewer/src/build_exe.py new file mode 100644 index 0000000..69b40fa --- /dev/null +++ b/EDID-viewer/src/build_exe.py @@ -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() diff --git a/EDID-viewer/src/edid.py b/EDID-viewer/src/edid.py new file mode 100644 index 0000000..9d68022 --- /dev/null +++ b/EDID-viewer/src/edid.py @@ -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>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<=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<=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<=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<-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() diff --git a/EDID-viewer/src/publish_gitea.py b/EDID-viewer/src/publish_gitea.py new file mode 100644 index 0000000..c2693cd --- /dev/null +++ b/EDID-viewer/src/publish_gitea.py @@ -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 --notes-file release/notes-v0.1.0.md + python publish_gitea.py --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() diff --git a/EDID-viewer/src/test_edid.py b/EDID-viewer/src/test_edid.py new file mode 100644 index 0000000..74aedf0 --- /dev/null +++ b/EDID-viewer/src/test_edid.py @@ -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() diff --git a/EDID-viewer/src/theme.py b/EDID-viewer/src/theme.py new file mode 100644 index 0000000..55a7c16 --- /dev/null +++ b/EDID-viewer/src/theme.py @@ -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)) diff --git a/EDID-viewer/src/windows_edid.py b/EDID-viewer/src/windows_edid.py new file mode 100644 index 0000000..1260a01 --- /dev/null +++ b/EDID-viewer/src/windows_edid.py @@ -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 + diff --git a/EDID-viewer/start.cmd b/EDID-viewer/start.cmd new file mode 100644 index 0000000..fcc7075 --- /dev/null +++ b/EDID-viewer/start.cmd @@ -0,0 +1,3 @@ +@echo off +cd /d "%~dp0" +start "" ".venv\Scripts\pythonw.exe" "src\main.py" diff --git a/EDID-viewer/validation/about_check.py b/EDID-viewer/validation/about_check.py new file mode 100644 index 0000000..b95e578 --- /dev/null +++ b/EDID-viewer/validation/about_check.py @@ -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() diff --git a/EDID-viewer/validation/capture-window.ps1 b/EDID-viewer/validation/capture-window.ps1 new file mode 100644 index 0000000..3343b68 --- /dev/null +++ b/EDID-viewer/validation/capture-window.ps1 @@ -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)" diff --git a/EDID-viewer/validation/live-0.bin b/EDID-viewer/validation/live-0.bin new file mode 100644 index 0000000000000000000000000000000000000000..5d40519287564bbb358c52a7a847384b596d6dd9 GIT binary patch literal 256 zcmZSh4+acA&D>55AfUv^vQ;JP-*K`NQBC;^$~);1OzIsB56Ww4*?wl8HfqfoGRvB-8;C42lf0CA^s& z3|tOu3{R^9TNm&2V&YjfSIwt^YaO8Q2-*8SL4P mGt>iFKrvQ?*nQFIqU;Qf4BU@d8yKFku`>2&F@X*EH~|1G=rsWV literal 0 HcmV?d00001 diff --git a/EDID-viewer/validation/live-0.json b/EDID-viewer/validation/live-0.json new file mode 100644 index 0000000..b991fa2 --- /dev/null +++ b/EDID-viewer/validation/live-0.json @@ -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 字节", + "范围描述符有扩展偏移,频率范围显示原始字段,暂未应用偏移" + ] +} \ No newline at end of file diff --git a/EDID-viewer/validation/sample-hdmi.bin b/EDID-viewer/validation/sample-hdmi.bin new file mode 100644 index 0000000000000000000000000000000000000000..b94e79a9bb1b7452fe8320863788c1defc4a4e28 GIT binary patch literal 256 zcmZSh4+acAx(raj$g&8=gwu>@fXS*sqR>LuAwtKM;jf$^1A`n;=^us=*WeI^07pMp rA1(z2xMn;I#zRcZ;ss6<5|ugG+0&VM7=%m=Dju`(FyhfeRjIWAGlCTX literal 0 HcmV?d00001 diff --git a/EDID-viewer/validation/sample-hdmi.hex b/EDID-viewer/validation/sample-hdmi.hex new file mode 100644 index 0000000..abc5313 --- /dev/null +++ b/EDID-viewer/validation/sample-hdmi.hex @@ -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 \ No newline at end of file