feat(EDID-Viewer): v0.1.0 入库 — Fluent EDID 查看器,目录对齐 DLPilot
- src/ 人写代码:main.py(Fluent 界面、Light/Dark/dark1/dark2/跟随系统主题、 关于按钮;主题三件套 = setTheme + QPalette/应用级 QSS + DWM 标题栏, 修复 Light 下窗口背景/页签/标题栏仍为系统深色的问题)、edid.py (EDID/CTA/HDMI/HDR 解析)、windows_edid.py(WMI 只读)、theme.py、 test_edid.py(9 项解析测试,含随机畸形数据无崩溃检查) - assets/ 图标与生成脚本;release/ 交付:EDID-Viewer.exe(onefile 63.7MB, 启动/图标/主题已验证)+ README,与 DLPilot/release 同约定 exe 直接入库 - 流水线对齐 DLPilot:src/build_exe.py(spec 由脚本生成不入库,剔除 PATH 上 Poppler/Conda 版本化 ICU,避免 QtCore 报 WinError 127)→ package_release.py(组装 EDID-Viewer-v*-win64.zip,zip 只上传 Gitea)→ publish_gitea.py(token 走 GITEA_TOKEN,自签证书 --insecure) - agent.md 更名 AGENTS.md(DLPilot 双文档约定:README 给人 / AGENTS.md 给 Agent);根 .gitignore 增加 EDID-viewer/release/*.zip - validation/ 保留 EDID 本机/合成样本与窗口截图、关于对话框自检脚本
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# 本机虚拟环境(根 .gitignore 已覆盖 build/ dist/ *.spec __pycache__/)
|
||||
.venv/
|
||||
|
||||
# 构建日志(测试结果由 unittest 实时输出, 不留档)
|
||||
build-log*.txt
|
||||
|
||||
# 验证脚本生成的截图
|
||||
validation/*.png
|
||||
@@ -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 与高级色彩数据解析。
|
||||
- 与实际输出时序和桥接芯片寄存器联合诊断。
|
||||
@@ -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**:适合高刷新率和高分辨率显示设备,作为下一阶段扩展。
|
||||
@@ -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 |
@@ -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)])
|
||||
@@ -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**:适合高刷新率和高分辨率显示设备,作为下一阶段扩展。
|
||||
@@ -0,0 +1,4 @@
|
||||
PySide6==6.11.2
|
||||
PySide6-Fluent-Widgets==1.11.3
|
||||
pyinstaller==6.22.2
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
start "" ".venv\Scripts\pythonw.exe" "src\main.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()
|
||||
@@ -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.
@@ -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.
@@ -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
|
||||
Reference in New Issue
Block a user