merge: DLPilot v0.3.0 — CustomTkinter 双栏界面重构 + 光机串口参数化 + TEC 控制
@@ -9,6 +9,7 @@ dist/
|
||||
|
||||
# 发布 zip: 可由 src/package_release.py 重新生成, 只上传 Gitea Release
|
||||
HDMI-Tool/release/*.zip
|
||||
DLPilot/release/*.zip
|
||||
|
||||
# ZCode 客户端本地会话数据(计划留档等), 非项目配置
|
||||
.zcode/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# AGENTS.md — HDMI-Tool 工程档案(供 AI/Agent 使用)
|
||||
# AGENTS.md — DLPilot 工程档案(供 AI/Agent 使用)
|
||||
|
||||
> **本文档面向 AI/Agent**,是人类文档 [README.md](README.md) 的姊妹篇,两者不重叠:
|
||||
> README 回答"这是什么、怎么用";本文回答"代码怎么组织、怎么构建发布、改动时哪些约束不能破坏"。
|
||||
@@ -9,17 +9,17 @@
|
||||
仓库 `LT8619C_Debug_Tools` 是 LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板的**调试上位机工具集**,
|
||||
上游主仓库(固件)为 `hulk/LT8619C_DLPC3421_HDMI`(同 Gitea 实例 `gitea.hulk.wang`),
|
||||
本仓库本机路径 `E:\Hulk-Coding\LT8619C_DLPC3421_HDMI`。
|
||||
HDMI-Tool 工作在两端:HDMI **源端**(PC 显卡侧,EDID/分辨率/图案)与**光机控制端**
|
||||
DLPilot 工作在两端:HDMI **源端**(PC 显卡侧,EDID/分辨率/图案)与**光机控制端**
|
||||
(RS485 → 板载 GD32E230 网关 → DLPC3421)。寄存器语义判读基线(0x606E 位交换 /
|
||||
0x606D 整组顺序)与光机协议规程都在上游仓库 `doc/architecture/` 与 `protocols.md`,不在本仓库。
|
||||
|
||||
## 2. 目录契约(重要)
|
||||
|
||||
```
|
||||
HDMI-Tool/
|
||||
DLPilot/
|
||||
├── src/ 源代码区 —— 只有这里可以手改(logos/ 改图标源后重跑 make_ico.py)
|
||||
├── assets/ 生成资产区 —— gen_*.py 的产物(PNG 图案),可随时再生成,改动需提交
|
||||
├── release/ 产物区 —— 机器生成,禁止手改:HDMI-Tool.exe、pattern_slots.json、
|
||||
├── release/ 产物区 —— 机器生成,禁止手改:DLPilot.exe、pattern_slots.json、
|
||||
│ bw/solid 图案(package_release.py 从 assets/ 平铺复制来,均入库)、
|
||||
│ *.zip(gitignore,只上传 Release)。release/ 本身=完整可直接运行的
|
||||
│ 发布文件夹(exe+附属平铺),内容与 zip 一致
|
||||
@@ -34,11 +34,16 @@ HDMI-Tool/
|
||||
|
||||
| 文件 | 角色 |
|
||||
|---|---|
|
||||
| `src/hdmi_gui.py` | 单文件主程序:GUI(tkinter) + 命令行 + 光机控制区, 纯标准库 + 可选依赖 |
|
||||
| `src/hdmi_gui.py` | 入口: argparse 命令行分发(--dump/--edid-fields/--test-pattern/--dlpc-cmd/--seconds), GUI 委托 app_gui |
|
||||
| `src/app_gui.py` | CustomTkinter 主界面: 双栏仪表盘(左 HDMI 源端五卡/右 光机四卡), 光强/定时快捷滑条 |
|
||||
| `src/display_win.py` | Win32 显示器枚举/分辨率切换/智能拓扑(纯 ctypes) |
|
||||
| `src/edid_parse.py` | EDID 1.4/CEA-861 解析(纯标准库, 上游 lt8619c_edid.c 以此为参考解码器) |
|
||||
| `src/patterns.py` | 测试图案全屏投屏窗口(tkinter, ESC 退出, 测试卡区域 1:1 居中) |
|
||||
| `src/config.py` | 运行目录定位(BASE_DIR)与 pattern_slots.json 读写(slots/area/serial 三键) |
|
||||
| `src/dlpc_rs485.py` | 光机 RS485 协议模块: 帧构造/增量解析/译码表/预设命令/串口线程(DLPC485) |
|
||||
| `src/selftest_rs485.py` | 光机协议自检(对照上游实测帧向量, 无硬件回归, 改协议代码后必跑) |
|
||||
| `src/build_exe.py` | PyInstaller 打包 → `release/HDMI-Tool.exe`(onefile/noconsole,内嵌图标+pyserial) |
|
||||
| `src/package_release.py` | 复制图案到 release/(平铺) → `release/HDMI-Tool-v<ver>-win64.zip`(校验栏位引用完整性; release/ 与 zip 内容一致) |
|
||||
| `src/build_exe.py` | PyInstaller 打包 → `release/DLPilot.exe`(onefile/noconsole, 内嵌图标+pyserial+customtkinter) |
|
||||
| `src/package_release.py` | 复制图案到 release/(平铺) → `release/DLPilot-v<ver>-win64.zip`(校验栏位引用完整性; release/ 与 zip 内容一致) |
|
||||
| `src/publish_gitea.py` | 建/复用 Gitea Release 并上传 zip(tag 默认取 zip 文件名) |
|
||||
| `src/hdmi_source.ps1/.bat` | 独立 PowerShell 命令行工具(自包含,与 GUI 无代码共享) |
|
||||
| `src/gen_bw_patterns.py` | 生成 `assets/bw_*.png` ×5(640x360 黑白,需 Pillow) |
|
||||
@@ -46,17 +51,21 @@ HDMI-Tool/
|
||||
| `src/la_wave_analyzer*.py` | 逻辑分析仪 CSV 波形判读(2/3 通道,40MHz 像素时钟假设) |
|
||||
| `src/logos/make_ico.py` | svg/png → ico(写在其自身目录) |
|
||||
| `release/pattern_slots.json` | 预置栏位:西门子星/波带片/综合测试卡,area=640x360;`serial` 键记忆光机串口 |
|
||||
| `UI参考布局.svg/.png` | 界面布局设计稿(app_gui.py 双栏布局的规格来源, 随分支入库) |
|
||||
|
||||
## 3. 代码不变量(改 hdmi_gui.py 必须维持)
|
||||
|
||||
1. **纯标准库 + 可选依赖**:hdmi_gui.py 核心只准用标准库(ctypes/winreg/tkinter/…)。
|
||||
可选依赖一律走"try-import + HAS_* 标志 + 缺失降级"模式:Pillow(自定义图片 JPG/BMP/WebP)、
|
||||
pyserial(光机控制)。缺失时对应功能禁用/提示安装,其余功能必须照常工作。
|
||||
2. **pyserial 只准在 dlpc_rs485.py 里 import**:hdmi_gui.py 通过 `dlpc_rs485.HAS_SERIAL`
|
||||
与 `DLPC485` 类间接使用,不得直接 import serial(协议与串口细节收敛在单一模块)。
|
||||
3. **BASE_DIR 语义**:frozen(PyInstaller onefile)时 `BASE_DIR=exe 所在目录`(`sys.executable`),
|
||||
源码运行时 `=hdmi_gui.py 所在目录`(`__file__`)。所有用户数据(pattern_slots.json、
|
||||
自定义图片)都写/读自 BASE_DIR **平铺**存放,不建子目录。
|
||||
1. **依赖分层**:CLI/显示/EDID/协议/配置模块(display_win/edid_parse/patterns/config/
|
||||
dlpc_rs485)纯标准库;customtkinter 是 GUI(app_gui)的**硬依赖**(装了才能开界面,
|
||||
CLI 不受影响)。可选依赖仍走"try-import + HAS_* 标志 + 缺失降级"模式:Pillow、
|
||||
pyserial——缺失时对应功能禁用/提示安装,其余功能必须照常工作。
|
||||
2. **模块边界**:hdmi_gui.py 只是入口, 不放实现; GUI 不直接碰 ctypes/winreg
|
||||
(显示器走 display_win, EDID 走 edid_parse); pyserial 只准在 dlpc_rs485.py 里 import
|
||||
(hdmi_gui/app_gui 经 `dlpc_rs485.HAS_SERIAL` 与 `DLPC485` 类间接使用)。
|
||||
3. **BASE_DIR 语义**(定义在 config.py):frozen(PyInstaller onefile)时
|
||||
`BASE_DIR=exe 所在目录`(`sys.executable`),源码运行时 `=config.py 所在目录`
|
||||
(即 src/)。所有用户数据(pattern_slots.json、自定义图片)都写/读自 BASE_DIR
|
||||
**平铺**存放,不建子目录。
|
||||
4. **自定义栏位 `file` 字段是裸文件名**:加载时 `os.path.join(BASE_DIR, file)` 解析,
|
||||
因此 pattern_slots.json 引用的图片必须与 exe 同目录 —— package_release.py 正是按此平铺打包。
|
||||
5. **exe 自包含**:图标经 `--add-data` 内嵌(运行时从 `_MEIPASS` 读);内置五种图案
|
||||
@@ -75,7 +84,13 @@ HDMI-Tool/
|
||||
与 I2C-Inject 的 Injector 同源);退出路径(关窗与 `--seconds` 自动关)都会走到
|
||||
`App.destroy()`,在那里 `dlpc.close()` 收线程。
|
||||
11. **pattern_slots.json 格式**:`{'area', 'slots', 'serial'}` 三键共存;load 兼容旧版
|
||||
纯数组(无 area)与无 serial 键两种历史格式,save 永远写全三键。
|
||||
纯数组(无 area)与无 serial 键两种历史格式,save 永远写全三键。`serial` 子键为
|
||||
`{port, baud, bytesize(8/7/6/5), parity(N/E/O), stopbits(1/2)}`,旧配置缺子键按
|
||||
默认 8-N-1 补齐。
|
||||
12. **GUI = app_gui.py 双栏仪表盘**(规格:`DLPilot/UI参考布局.svg`):左列 HDMI 源端
|
||||
五卡(显示器/模式/EDID 信息/测试图案/hex),右列光机四卡(串口/数据/命令/滑条),
|
||||
顶栏 Dark/Light/System 切换(ttk.Treeview 颜色随动)。打包必须 `--collect-all
|
||||
customtkinter`(build_exe.py 已内置,勿删)——CTk 主题/字体数据缺失会让 exe 启动即崩。
|
||||
|
||||
## 4. 领域语义(测试值都有含义,勿"顺手改")
|
||||
|
||||
@@ -96,14 +111,14 @@ HDMI-Tool/
|
||||
|
||||
```powershell
|
||||
python gen_bw_patterns.py ; python gen_solid_colors.py # 仅当改了图案生成逻辑
|
||||
python build_exe.py # → ../release/HDMI-Tool.exe(缺 PyInstaller 会自动 pip install)
|
||||
python build_exe.py --debug # 附加带控制台的 HDMI-Tool-console.exe(调试 --dump 用)
|
||||
python package_release.py <版本> # → ../release/HDMI-Tool-v<版本>-win64.zip
|
||||
python publish_gitea.py HDMI-Tool-v<版本>-win64.zip --replace --insecure # 上传(需 GITEA_TOKEN)
|
||||
python build_exe.py # → ../release/DLPilot.exe(缺 PyInstaller 会自动 pip install)
|
||||
python build_exe.py --debug # 附加带控制台的 DLPilot-console.exe(调试 --dump 用)
|
||||
python package_release.py <版本> # → ../release/DLPilot-v<版本>-win64.zip
|
||||
python publish_gitea.py DLPilot-v<版本>-win64.zip --replace --insecure # 上传(需 GITEA_TOKEN)
|
||||
```
|
||||
|
||||
发布约定:
|
||||
- tag/release 命名 `HDMI-Tool-vX.Y.Z`,zip 命名 `HDMI-Tool-vX.Y.Z-win64.zip`(publish_gitea.py
|
||||
- tag/release 命名 `DLPilot-vX.Y.Z`,zip 命名 `DLPilot-vX.Y.Z-win64.zip`(publish_gitea.py
|
||||
依赖此命名从文件名推 tag);
|
||||
- **exe、pattern_slots.json 与 release/ 下平铺的图案 png 改动跟随源码/资产生成提交入库;
|
||||
zip 永不入库**(.gitignore 已挡);
|
||||
@@ -117,21 +132,24 @@ python publish_gitea.py HDMI-Tool-v<版本>-win64.zip --replace --insecure #
|
||||
|
||||
## 6. 已知坑(历史踩过,勿重蹈)
|
||||
|
||||
- onefile 运行时 `__file__` 指向临时解压目录 `_MEIPASS`,定位 exe 必须用 `sys.executable`(§3.2);
|
||||
- onefile 运行时 `__file__` 指向临时解压目录 `_MEIPASS`,定位 exe 必须用 `sys.executable`(§3.3);
|
||||
- 重新打包前旧 exe 可能被残留进程占用 → build_exe.py 会 taskkill 同名进程,手改打包脚本时保留;
|
||||
- 打包后本机图标缓存不刷新:`ie4uinit -show`,不是产物问题;
|
||||
- 改图标流程:改 svg → `make_ico.py` 生成 ico → 重新打包(ico 同时是文件图标与运行时窗口图标);
|
||||
- GUI 与 hdmi_source.ps1 各自独立实现显示器枚举,行为可能微差(GUI 与 `-List` 序号一致,
|
||||
但与"设置"面板序号无保证);多屏操作前先确认序号;
|
||||
- `pattern_slots.json` 兼容两种格式:新 `{area, slots[]}`,旧纯数组(无 area)——load_slots 已兼容,
|
||||
但写出只应写新格式。
|
||||
- `pattern_slots.json` 兼容两种历史格式,写出只写新格式(详见 §3.11);
|
||||
- CustomTkinter 6.x:`CTkComboBox` 不支持 `textvariable`(用 `.set()/.get()`);`CTkTextbox`
|
||||
的 `tag_config` 在部分版本不可用,app_gui 已 try/except 兜底为单色;同一容器内
|
||||
grid/pack 不可混用(卡片用 grid 挂进列,卡片内部一律 pack——`_card` 返回 (卡片, 内容));
|
||||
|
||||
## 7. 验证清单(改完代码后)
|
||||
|
||||
1. `python selftest_rs485.py` —— 光机协议回归(改 dlpc_rs485.py 后必跑,向量来自上游 protocols.md);
|
||||
2. `python hdmi_gui.py --seconds 5` —— GUI 冒烟(自动开关);
|
||||
3. `python hdmi_gui.py --test-pattern Checker --area 800x600 --seconds 3` —— 投图链路;
|
||||
4. `python hdmi_gui.py --dump` —— EDID 解算不抛异常;
|
||||
5. `python hdmi_gui.py --dlpc-cmd M999`(不带 --port)—— 应明确报错而非崩溃(无硬件时的优雅降级);
|
||||
6. `python build_exe.py` → 双击 release/HDMI-Tool.exe 确认图标/启动/投图/光机区渲染;
|
||||
7. `python package_release.py <版本>` —— 不报"引用图片缺失"即栏位完整性 OK。
|
||||
1. `python -m py_compile src/*.py` —— 全模块可编译;
|
||||
2. `python selftest_rs485.py` —— 光机协议回归(改 dlpc_rs485.py 后必跑,向量来自上游 protocols.md);
|
||||
3. `python hdmi_gui.py --seconds 5` —— GUI 冒烟(自动开关);
|
||||
4. `python hdmi_gui.py --test-pattern Checker --area 800x600 --seconds 3` —— 投图链路;
|
||||
5. `python hdmi_gui.py --dump` —— EDID 解算不抛异常;
|
||||
6. `python hdmi_gui.py --dlpc-cmd M999`(不带 --port)—— 应明确报错而非崩溃(无硬件时的优雅降级);
|
||||
7. `python build_exe.py` → 双击 release/DLPilot.exe 确认图标/双栏布局/投图/光机区渲染;
|
||||
8. `python package_release.py <版本>` —— 不报"引用图片缺失"即栏位完整性 OK。
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# HDMI-Tool — HDMI 源端调试 + 光机控制工具
|
||||
# DLPilot — HDMI 源端调试 + 光机控制工具
|
||||
|
||||
> LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套的 PC 端工具:
|
||||
> HDMI **源端**(EDID 查看、分辨率切换、纯色/测试图案投屏)+ **光机控制**
|
||||
> (RS485 直连板上 GD32 网关,开关光机/设光强/查温度电流)。
|
||||
>
|
||||
> - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases)
|
||||
> 下载 `HDMI-Tool-vX.Y.Z-win64.zip`,解压双击 `HDMI-Tool.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
|
||||
> 下载 `DLPilot-vX.Y.Z-win64.zip`,解压双击 `DLPilot.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
|
||||
> - **要改代码/重新打包**:见 §8、§9。
|
||||
>
|
||||
> 更新:2026-09-11
|
||||
@@ -20,7 +20,8 @@
|
||||
| EDID 逐字节解析 | "EDID 原始数据"框右上"逐字节解析"按钮:按偏移逐字段展开基础块与 CTA-861 扩展块,每字段附原始字节 hex;命令行等价 `--edid-fields` |
|
||||
| 分辨率切换 | "分辨率"下拉列出显卡按该屏 EDID 提供的全部模式;**应用**=临时切换(不写注册表,重启/恢复即还原),**恢复**=回到本次会话切改前的模式 |
|
||||
| 测试图案投屏 | 全屏投到所选显示器,测试卡区域可选 `640x360 / 800x600 / full 铺满`;内置 纯红/纯绿/纯蓝/黑白棋盘格/网格线 + R/G/B 三行 55/AA/FF 纯色值按钮 + 3 个自定义图片栏位 |
|
||||
| 光机控制 | RS485 → 板载 GD32E230 网关 → DLPC3421:开关光机(可定时)、设光强、查电流/温度/版本、TEC 控制、软件重启;收发日志带协议译码(见 §5) |
|
||||
| 界面 | CustomTkinter 深色双栏仪表盘(左列 HDMI 源端 / 右列 光机控制),顶栏可切换 Dark/Light/System |
|
||||
| 光机控制 | RS485 → 板载 GD32E230 网关 → DLPC3421:开关光机(可定时)、设光强、查电流/温度/版本、TEC 控制、软件重启;光强/定时提供快捷滑条;收发日志带协议译码(见 §5) |
|
||||
| 智能显示拓扑 | 仅当检测到转接板(EDID 厂商 "XLS")接入但未进入桌面(Windows 卡在"仅电脑屏幕")时才强制扩展把它拉回;你主动选的"仅第二屏幕"等拓扑不会被改动 |
|
||||
| 命令行模式 | 全部功能可脚本化调用,供自动化测试(见 §6、§7) |
|
||||
| 波形分析脚本 | 逻辑分析仪 CSV 波形按占空比签名分段,输出行结构/占空比/消隐明细(见 §7.3) |
|
||||
@@ -28,15 +29,15 @@
|
||||
## 2. 下载与安装
|
||||
|
||||
1. 打开 [Releases 页面](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases),取最新的
|
||||
`HDMI-Tool-vX.Y.Z-win64.zip`;
|
||||
`DLPilot-vX.Y.Z-win64.zip`;
|
||||
2. 解压到**任意可写目录**(自定义图片与栏位配置会写到 exe 旁边);
|
||||
3. 双击 `HDMI-Tool.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。
|
||||
3. 双击 `DLPilot.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。
|
||||
|
||||
包内包含:
|
||||
|
||||
```
|
||||
HDMI-Tool/
|
||||
├── HDMI-Tool.exe 主程序(单文件自包含, 光机控制的 pyserial 已内置)
|
||||
DLPilot/
|
||||
├── DLPilot.exe 主程序(单文件自包含, 光机控制的 pyserial 已内置)
|
||||
├── pattern_slots.json 预置自定义栏位 + 串口记忆
|
||||
├── bw_*.png ×5 640x360 黑白测试图
|
||||
├── solid_*.png ×10 800x600 纯色测试图
|
||||
@@ -45,29 +46,35 @@ HDMI-Tool/
|
||||
|
||||
## 3. GUI 使用
|
||||
|
||||
主窗口自上而下四块区域:
|
||||
主窗口为双栏仪表盘(布局设计稿:`UI参考布局.svg`),默认跟随系统深/浅色外观
|
||||
(顶栏可切换 Dark/Light/System),顶栏右侧"关于"按钮内含软件信息:
|
||||
|
||||
```
|
||||
┌ 显示器: [ ... ▼] [刷新] ┐
|
||||
├ 分辨率: [800x600@60 ▼] [应用] [恢复] ├ 显示器/EDID
|
||||
│ 支持的模式(EDID 解算) | EDID 信息 ┘
|
||||
├ 测试图案 — 图案按钮 / R,G,B 0x55,0xAA,0xFF / 测试卡区域 / 自定义栏位 ┐
|
||||
├ 光机控制 — 串口 [连接] / 开关光机 / 光强 / 命令下拉 / 自定义命令 ┘ 投屏+光机
|
||||
│ └ 收发日志(带协议译码与原始 hex)
|
||||
└ EDID 原始数据 — hex dump [逐字节解析] ┘
|
||||
左列(HDMI 源端) 右列(光机控制 RS485)
|
||||
┌ 显示器选择区域 [刷新] ┐ ┌ 串口选择 + 波特率/8-N-1 参数/连接 ┐
|
||||
├ 支持的模式: 分辨率[应用][恢复] + 明细 ┐ ├ 串口数据 — 收发日志(译码+hex) ┤
|
||||
├ EDID 信息(表格) ┘ ├ 光机命令 — 快捷按钮+TEC │
|
||||
├ 测试图案 / 纯色 / 自定义 / 测试卡区域 │ 预设下拉 / 自定义 / 温度读取 │
|
||||
├ EDID 原始数据 [逐字节解析] ┘ └ 快捷滑条 — 光强/定时/TEC温度 ┘
|
||||
```
|
||||
|
||||
1. 顶部显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举;
|
||||
2. **切分辨率**:选模式 → "应用";测完点"恢复";
|
||||
3. **投测试图**:先选**测试卡区域**(640x360 / 800x600 / full)→ 点图案按钮全屏投放,
|
||||
按 **ESC** 或点击退出;R/G/B 三行纯色按钮(55/AA/FF)用于 RGB 位交换类测试
|
||||
(55 与 AA 互为字节镜像对,FF 全高基准);自定义图片点"⚙"配置(图片自动复制到 exe 旁);
|
||||
4. 区域与自定义栏位配置自动保存在 exe 旁的 `pattern_slots.json`。
|
||||
1. 左上显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举;
|
||||
2. **切分辨率**:"支持的模式"卡片里选模式 → "应用";测完点"恢复";
|
||||
3. **投测试图**:"测试图案"卡片先选**测试卡区域**(640x360 / 800x600 / full)→
|
||||
点图案按钮全屏投放,按 **ESC** 或点击退出;R/G/B 纯色按钮(55/AA/FF,按通道着色)
|
||||
用于 RGB 位交换类测试;自定义图片点齿轮按钮配置(图片自动复制到 exe 旁),
|
||||
"校准图案"一排为 3D 光固化测试/校准图预留槽位;
|
||||
4. **光机控制**:右列选串口与参数(波特率/数据位/校验/停止位,默认 115200 8-N-1)→ 连接 →
|
||||
快捷按钮/预设下拉/自定义输入(回车即发);"查DMD温度""查LAMP温度"读温度
|
||||
(应答实时填入按钮旁的显示框);快捷滑条:光强拖动后"下发电流值",
|
||||
定时毫秒后"开光机(定时)"(0=不限时 M730S0),TEC 凝露点滑条/输入框
|
||||
(200-350 即 20.0-35.0°C)后"下发温度值";数值框输入后回车自动限幅同步;
|
||||
5. 测试卡区域、自定义栏位与串口记忆(含参数)自动保存在 exe 旁的 `pattern_slots.json`。
|
||||
|
||||
## 4. 典型测试场景
|
||||
|
||||
```
|
||||
1. HDMI 接转接板 → 启动 HDMI-Tool → 刷新 → 选中板卡显示器
|
||||
1. HDMI 接转接板 → 启动 DLPilot → 刷新 → 选中板卡显示器
|
||||
2. 板卡 shadow EDID 首选 640x360@60 → 测试卡区域选 640x360(与光机面板原生 1:1)
|
||||
3. "光机控制"连接串口 → M999 查版本确认链路通 → M730S0 开光机
|
||||
4. 点 R/G/B 行纯色按钮投图 → 目视/逻辑分析仪核对 RGB888 输出
|
||||
@@ -175,10 +182,15 @@ python la_wave_analyzer3.py <capture.csv> # 3 通道(RGB 三组同拍, ch8/
|
||||
## 8. 目录结构与从源码运行
|
||||
|
||||
```
|
||||
HDMI-Tool/
|
||||
DLPilot/
|
||||
├── src/ # 源代码(人工维护区)
|
||||
│ ├── hdmi_gui.py # 主程序:GUI + 命令行(标准库; pyserial/Pillow 可选)
|
||||
│ ├── dlpc_rs485.py # 光机 RS485 协议与串口链路(帧/解析/译码/线程)
|
||||
│ ├── hdmi_gui.py # 入口与命令行模式(argparse 分发)
|
||||
│ ├── app_gui.py # CustomTkinter 主界面(双栏仪表盘)
|
||||
│ ├── display_win.py # Win32 显示器枚举/分辨率/智能拓扑(纯标准库)
|
||||
│ ├── edid_parse.py # EDID 1.4/CEA-861 解析(纯标准库)
|
||||
│ ├── patterns.py # 测试图案全屏投屏窗口
|
||||
│ ├── config.py # 运行目录与 pattern_slots.json 配置
|
||||
│ ├── dlpc_rs485.py # 光机 RS485 协议与串口链路
|
||||
│ ├── selftest_rs485.py # 光机协议自检(无硬件回归)
|
||||
│ ├── build_exe.py # PyInstaller 打包 → release/
|
||||
│ ├── package_release.py # 组装发布 zip(exe+图案+说明)
|
||||
@@ -192,26 +204,29 @@ HDMI-Tool/
|
||||
│ ├── bw_*.png ×5 # 640x360 黑白系列
|
||||
│ └── solid_*.png ×10 # 800x600 纯色系列
|
||||
├── release/ # 构建与发布产物(机器生成区, 不手改)
|
||||
│ ├── HDMI-Tool.exe # 打包单文件版(改动跟随源码提交入库)
|
||||
│ ├── DLPilot.exe # 打包单文件版(改动跟随源码提交入库)
|
||||
│ ├── pattern_slots.json # exe 运行配置(栏位预置 + 串口记忆)
|
||||
│ ├── *.png / README.md # 附属文件平铺副本(由 package_release.py 从 assets/ 同步)
|
||||
│ └── *.zip # 发布包(不入库, 上传 Gitea Release)
|
||||
├── README.md # 本说明
|
||||
└── AGENTS.md # 面向 AI/Agent 的工程档案(结构/约定/构建规程)
|
||||
├── AGENTS.md # 面向 AI/Agent 的工程档案(结构/约定/构建规程)
|
||||
└── UI参考布局.svg/.png # 界面布局设计稿(app_gui.py 按此实现)
|
||||
```
|
||||
|
||||
从源码运行:`python src/hdmi_gui.py`,需 Python ≥3.10,标准库即可跑 EDID/投屏;
|
||||
光机控制需 pyserial(`py -m pip install pyserial`),自定义图片要 JPG/BMP/WebP 需可选 Pillow。
|
||||
从源码运行:`python src/hdmi_gui.py`,需 Python ≥3.10;EDID/命令行纯标准库;
|
||||
GUI 需 customtkinter(`py -m pip install customtkinter`);光机控制需 pyserial;
|
||||
自定义图片要 JPG/BMP/WebP 需可选 Pillow。
|
||||
|
||||
## 9. 构建与发布
|
||||
|
||||
```powershell
|
||||
cd src
|
||||
python selftest_rs485.py # 光机协议回归(改 dlpc_rs485.py 后必跑)
|
||||
python build_exe.py # 打包 → ../release/HDMI-Tool.exe(自动装 PyInstaller; 内置 pyserial)
|
||||
python build_exe.py # 打包 → ../release/DLPilot.exe(自动装 PyInstaller;
|
||||
# 内置 pyserial/customtkinter, CTk 主题数据自动收集)
|
||||
python build_exe.py --debug # 额外出一个带控制台的调试变体(--dump 可见输出)
|
||||
python package_release.py 0.2.0 # 组装 release/HDMI-Tool-v0.2.0-win64.zip
|
||||
python publish_gitea.py HDMI-Tool-v0.2.0-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN)
|
||||
python package_release.py 0.2.0 # 组装 release/DLPilot-v0.2.0-win64.zip
|
||||
python publish_gitea.py DLPilot-v0.2.0-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN)
|
||||
```
|
||||
|
||||
## 10. 注意事项 / FAQ
|
||||
@@ -222,6 +237,6 @@ python publish_gitea.py HDMI-Tool-v0.2.0-win64.zip --insecure # 上传 Gitea R
|
||||
- **exe 放只读目录会怎样**:自定义图片与 `pattern_slots.json` 写在 exe 旁边,只读目录会导致配置存不下;
|
||||
- **重新打包后图标没变**:Windows 图标缓存,`ie4uinit -show` 或改个名即可;
|
||||
- **多屏序号**:切分辨率前先确认显示器序号(GUI 下拉框与 `hdmi_source.bat -List` 输出一致);
|
||||
- **残留进程**:修改源码重新打包前先结束残留的 HDMI-Tool 进程(`build_exe.py` 会自动 taskkill);
|
||||
- **残留进程**:修改源码重新打包前先结束残留的 DLPilot 进程(`build_exe.py` 会自动 taskkill);
|
||||
- **改 logo**:改 `src/logos/logo_ph_orange.svg` → `python src/logos/make_ico.py` → 重新打包;
|
||||
- **配置互不影响**:exe 与源码脚本各自按所在目录保存 `pattern_slots.json`,两份安装互不干扰。
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="343.44351mm"
|
||||
height="287.24921mm"
|
||||
viewBox="0 0 343.44351 287.24921"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-276.89459,-7.1259639)">
|
||||
<rect
|
||||
style="fill:none;stroke:#000000;stroke-width:0.264583"
|
||||
id="rect2"
|
||||
width="107.61474"
|
||||
height="17.995777"
|
||||
x="310.24719"
|
||||
y="19.795353" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:7.05556px;text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="329.95258"
|
||||
y="30.952734"
|
||||
id="text2"><tspan
|
||||
id="tspan2"
|
||||
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="329.95258"
|
||||
y="30.952734">显示器选择区域</tspan></text>
|
||||
<rect
|
||||
style="fill:none;stroke:#000000;stroke-width:0.264583"
|
||||
id="rect2-8"
|
||||
width="107.61474"
|
||||
height="17.995777"
|
||||
x="476.34061"
|
||||
y="19.478064" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:7.05556px;text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="496.04599"
|
||||
y="30.635443"
|
||||
id="text2-8"><tspan
|
||||
id="tspan2-2"
|
||||
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="496.04599"
|
||||
y="30.635443">串口选择区域</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:7.05556px;text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="498.63492"
|
||||
y="52.226608"
|
||||
id="text2-8-7"><tspan
|
||||
id="tspan2-2-6"
|
||||
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="498.63492"
|
||||
y="52.226608">串口参数选择显示</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:7.05556px;text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="341.34952"
|
||||
y="75.572121"
|
||||
id="text2-8-1"><tspan
|
||||
id="tspan2-2-7"
|
||||
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="341.34952"
|
||||
y="75.572121">支持的模式</tspan></text>
|
||||
<rect
|
||||
style="fill:none;stroke:#000000;stroke-width:0.264583"
|
||||
id="rect3"
|
||||
width="150.08478"
|
||||
height="73.782684"
|
||||
x="296.5704"
|
||||
y="45.709274" />
|
||||
<rect
|
||||
style="fill:none;stroke:#000000;stroke-width:0.219727"
|
||||
id="rect4"
|
||||
width="149.76971"
|
||||
height="63.030079"
|
||||
x="297.26779"
|
||||
y="124.14843" />
|
||||
<rect
|
||||
style="fill:none;stroke:#000000;stroke-width:0.324039"
|
||||
id="rect4-21"
|
||||
width="148.22574"
|
||||
height="138.50803"
|
||||
x="462.45538"
|
||||
y="65.445747" />
|
||||
<rect
|
||||
style="fill:none;stroke:#000000;stroke-width:0.178131"
|
||||
id="rect4-2"
|
||||
width="150.89105"
|
||||
height="41.116825"
|
||||
x="297.18097"
|
||||
y="197.28166" />
|
||||
<rect
|
||||
style="fill:none;stroke:#000000;stroke-width:0.183274"
|
||||
id="rect4-2-4"
|
||||
width="150.52599"
|
||||
height="43.631092"
|
||||
x="297.79538"
|
||||
y="244.01398" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="344.43915"
|
||||
y="162.68181"
|
||||
id="text4"><tspan
|
||||
id="tspan4"
|
||||
style="fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="344.43915"
|
||||
y="162.68181">EDID信息</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="309.88724"
|
||||
y="220.62822"
|
||||
id="text6"><tspan
|
||||
id="tspan6"
|
||||
style="stroke-width:0.264583"
|
||||
x="309.88724"
|
||||
y="220.62822">测试的图案、纯色、自定义图案等区域</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="342.27966"
|
||||
y="259.85901"
|
||||
id="text7"><tspan
|
||||
id="tspan7"
|
||||
style="stroke-width:0.264583"
|
||||
x="342.27966"
|
||||
y="259.85901">EDID原始数据</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="511.7999"
|
||||
y="118.41221"
|
||||
id="text8"><tspan
|
||||
id="tspan8"
|
||||
style="stroke-width:0.264583"
|
||||
x="511.7999"
|
||||
y="118.41221">串口数据区域</tspan></text>
|
||||
<rect
|
||||
style="fill:none;stroke:#000000;stroke-width:0.264583"
|
||||
id="rect8"
|
||||
width="145.40587"
|
||||
height="70.903358"
|
||||
x="464.29105"
|
||||
y="213.78983" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="498.483"
|
||||
y="240.06366"
|
||||
id="text9"><tspan
|
||||
id="tspan9"
|
||||
style="fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="498.483"
|
||||
y="240.06366">预设按钮、自定义命令</tspan><tspan
|
||||
style="fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="498.483"
|
||||
y="248.96394"
|
||||
id="tspan10">亮度、时间等快捷滑条区域</tspan></text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 51 KiB |
@@ -1,11 +1,11 @@
|
||||
# HDMI-Tool — HDMI 源端调试 + 光机控制工具
|
||||
# DLPilot — HDMI 源端调试 + 光机控制工具
|
||||
|
||||
> LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套的 PC 端工具:
|
||||
> HDMI **源端**(EDID 查看、分辨率切换、纯色/测试图案投屏)+ **光机控制**
|
||||
> (RS485 直连板上 GD32 网关,开关光机/设光强/查温度电流)。
|
||||
>
|
||||
> - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases)
|
||||
> 下载 `HDMI-Tool-vX.Y.Z-win64.zip`,解压双击 `HDMI-Tool.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
|
||||
> 下载 `DLPilot-vX.Y.Z-win64.zip`,解压双击 `DLPilot.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
|
||||
> - **要改代码/重新打包**:见 §8、§9。
|
||||
>
|
||||
> 更新:2026-09-11
|
||||
@@ -20,7 +20,8 @@
|
||||
| EDID 逐字节解析 | "EDID 原始数据"框右上"逐字节解析"按钮:按偏移逐字段展开基础块与 CTA-861 扩展块,每字段附原始字节 hex;命令行等价 `--edid-fields` |
|
||||
| 分辨率切换 | "分辨率"下拉列出显卡按该屏 EDID 提供的全部模式;**应用**=临时切换(不写注册表,重启/恢复即还原),**恢复**=回到本次会话切改前的模式 |
|
||||
| 测试图案投屏 | 全屏投到所选显示器,测试卡区域可选 `640x360 / 800x600 / full 铺满`;内置 纯红/纯绿/纯蓝/黑白棋盘格/网格线 + R/G/B 三行 55/AA/FF 纯色值按钮 + 3 个自定义图片栏位 |
|
||||
| 光机控制 | RS485 → 板载 GD32E230 网关 → DLPC3421:开关光机(可定时)、设光强、查电流/温度/版本、TEC 控制、软件重启;收发日志带协议译码(见 §5) |
|
||||
| 界面 | CustomTkinter 深色双栏仪表盘(左列 HDMI 源端 / 右列 光机控制),顶栏可切换 Dark/Light/System |
|
||||
| 光机控制 | RS485 → 板载 GD32E230 网关 → DLPC3421:开关光机(可定时)、设光强、查电流/温度/版本、TEC 控制、软件重启;光强/定时提供快捷滑条;收发日志带协议译码(见 §5) |
|
||||
| 智能显示拓扑 | 仅当检测到转接板(EDID 厂商 "XLS")接入但未进入桌面(Windows 卡在"仅电脑屏幕")时才强制扩展把它拉回;你主动选的"仅第二屏幕"等拓扑不会被改动 |
|
||||
| 命令行模式 | 全部功能可脚本化调用,供自动化测试(见 §6、§7) |
|
||||
| 波形分析脚本 | 逻辑分析仪 CSV 波形按占空比签名分段,输出行结构/占空比/消隐明细(见 §7.3) |
|
||||
@@ -28,15 +29,15 @@
|
||||
## 2. 下载与安装
|
||||
|
||||
1. 打开 [Releases 页面](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases),取最新的
|
||||
`HDMI-Tool-vX.Y.Z-win64.zip`;
|
||||
`DLPilot-vX.Y.Z-win64.zip`;
|
||||
2. 解压到**任意可写目录**(自定义图片与栏位配置会写到 exe 旁边);
|
||||
3. 双击 `HDMI-Tool.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。
|
||||
3. 双击 `DLPilot.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。
|
||||
|
||||
包内包含:
|
||||
|
||||
```
|
||||
HDMI-Tool/
|
||||
├── HDMI-Tool.exe 主程序(单文件自包含, 光机控制的 pyserial 已内置)
|
||||
DLPilot/
|
||||
├── DLPilot.exe 主程序(单文件自包含, 光机控制的 pyserial 已内置)
|
||||
├── pattern_slots.json 预置自定义栏位 + 串口记忆
|
||||
├── bw_*.png ×5 640x360 黑白测试图
|
||||
├── solid_*.png ×10 800x600 纯色测试图
|
||||
@@ -45,29 +46,35 @@ HDMI-Tool/
|
||||
|
||||
## 3. GUI 使用
|
||||
|
||||
主窗口自上而下四块区域:
|
||||
主窗口为双栏仪表盘(布局设计稿:`UI参考布局.svg`),默认跟随系统深/浅色外观
|
||||
(顶栏可切换 Dark/Light/System),顶栏右侧"关于"按钮内含软件信息:
|
||||
|
||||
```
|
||||
┌ 显示器: [ ... ▼] [刷新] ┐
|
||||
├ 分辨率: [800x600@60 ▼] [应用] [恢复] ├ 显示器/EDID
|
||||
│ 支持的模式(EDID 解算) | EDID 信息 ┘
|
||||
├ 测试图案 — 图案按钮 / R,G,B 0x55,0xAA,0xFF / 测试卡区域 / 自定义栏位 ┐
|
||||
├ 光机控制 — 串口 [连接] / 开关光机 / 光强 / 命令下拉 / 自定义命令 ┘ 投屏+光机
|
||||
│ └ 收发日志(带协议译码与原始 hex)
|
||||
└ EDID 原始数据 — hex dump [逐字节解析] ┘
|
||||
左列(HDMI 源端) 右列(光机控制 RS485)
|
||||
┌ 显示器选择区域 [刷新] ┐ ┌ 串口选择 + 波特率/8-N-1 参数/连接 ┐
|
||||
├ 支持的模式: 分辨率[应用][恢复] + 明细 ┐ ├ 串口数据 — 收发日志(译码+hex) ┤
|
||||
├ EDID 信息(表格) ┘ ├ 光机命令 — 快捷按钮+TEC │
|
||||
├ 测试图案 / 纯色 / 自定义 / 测试卡区域 │ 预设下拉 / 自定义 / 温度读取 │
|
||||
├ EDID 原始数据 [逐字节解析] ┘ └ 快捷滑条 — 光强/定时/TEC温度 ┘
|
||||
```
|
||||
|
||||
1. 顶部显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举;
|
||||
2. **切分辨率**:选模式 → "应用";测完点"恢复";
|
||||
3. **投测试图**:先选**测试卡区域**(640x360 / 800x600 / full)→ 点图案按钮全屏投放,
|
||||
按 **ESC** 或点击退出;R/G/B 三行纯色按钮(55/AA/FF)用于 RGB 位交换类测试
|
||||
(55 与 AA 互为字节镜像对,FF 全高基准);自定义图片点"⚙"配置(图片自动复制到 exe 旁);
|
||||
4. 区域与自定义栏位配置自动保存在 exe 旁的 `pattern_slots.json`。
|
||||
1. 左上显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举;
|
||||
2. **切分辨率**:"支持的模式"卡片里选模式 → "应用";测完点"恢复";
|
||||
3. **投测试图**:"测试图案"卡片先选**测试卡区域**(640x360 / 800x600 / full)→
|
||||
点图案按钮全屏投放,按 **ESC** 或点击退出;R/G/B 纯色按钮(55/AA/FF,按通道着色)
|
||||
用于 RGB 位交换类测试;自定义图片点齿轮按钮配置(图片自动复制到 exe 旁),
|
||||
"校准图案"一排为 3D 光固化测试/校准图预留槽位;
|
||||
4. **光机控制**:右列选串口与参数(波特率/数据位/校验/停止位,默认 115200 8-N-1)→ 连接 →
|
||||
快捷按钮/预设下拉/自定义输入(回车即发);"查DMD温度""查LAMP温度"读温度
|
||||
(应答实时填入按钮旁的显示框);快捷滑条:光强拖动后"下发电流值",
|
||||
定时毫秒后"开光机(定时)"(0=不限时 M730S0),TEC 凝露点滑条/输入框
|
||||
(200-350 即 20.0-35.0°C)后"下发温度值";数值框输入后回车自动限幅同步;
|
||||
5. 测试卡区域、自定义栏位与串口记忆(含参数)自动保存在 exe 旁的 `pattern_slots.json`。
|
||||
|
||||
## 4. 典型测试场景
|
||||
|
||||
```
|
||||
1. HDMI 接转接板 → 启动 HDMI-Tool → 刷新 → 选中板卡显示器
|
||||
1. HDMI 接转接板 → 启动 DLPilot → 刷新 → 选中板卡显示器
|
||||
2. 板卡 shadow EDID 首选 640x360@60 → 测试卡区域选 640x360(与光机面板原生 1:1)
|
||||
3. "光机控制"连接串口 → M999 查版本确认链路通 → M730S0 开光机
|
||||
4. 点 R/G/B 行纯色按钮投图 → 目视/逻辑分析仪核对 RGB888 输出
|
||||
@@ -86,8 +93,10 @@ HDMI-Tool/
|
||||
`protocols.md` 为准:命令帧 `D5 01 LEN <ASCII命令> CRC`、响应帧 `B5 TYPE LEN <payload> CRC`,
|
||||
CRC 为第 2 字节起累加和取低 8 位。
|
||||
|
||||
GUI"光机控制"区:选串口 → 连接 → 点快捷按钮或命令下拉/自定义输入发送;日志区实时显示
|
||||
`→ 命令` / `← 译码结果 (原始 hex)`,错误自动附释义(如 `err:3(I2C NACK)`)。
|
||||
GUI"光机控制"区:选串口 → 连接 → 点快捷按钮或命令下拉/自定义输入发送;串口下拉显示
|
||||
"COMx + 设备描述",CH344 等多路适配器每一路描述不同(如 serial-A/B/C/D),据此选口;
|
||||
多口描述相同时自动附物理位置消歧。日志区实时显示 `→ 命令` / `← 译码结果 (原始 hex)`,
|
||||
错误自动附释义(如 `err:3(I2C NACK)`)。
|
||||
|
||||
| 命令 | 功能 | 响应示例 |
|
||||
|---|---|---|
|
||||
@@ -173,10 +182,15 @@ python la_wave_analyzer3.py <capture.csv> # 3 通道(RGB 三组同拍, ch8/
|
||||
## 8. 目录结构与从源码运行
|
||||
|
||||
```
|
||||
HDMI-Tool/
|
||||
DLPilot/
|
||||
├── src/ # 源代码(人工维护区)
|
||||
│ ├── hdmi_gui.py # 主程序:GUI + 命令行(标准库; pyserial/Pillow 可选)
|
||||
│ ├── dlpc_rs485.py # 光机 RS485 协议与串口链路(帧/解析/译码/线程)
|
||||
│ ├── hdmi_gui.py # 入口与命令行模式(argparse 分发)
|
||||
│ ├── app_gui.py # CustomTkinter 主界面(双栏仪表盘)
|
||||
│ ├── display_win.py # Win32 显示器枚举/分辨率/智能拓扑(纯标准库)
|
||||
│ ├── edid_parse.py # EDID 1.4/CEA-861 解析(纯标准库)
|
||||
│ ├── patterns.py # 测试图案全屏投屏窗口
|
||||
│ ├── config.py # 运行目录与 pattern_slots.json 配置
|
||||
│ ├── dlpc_rs485.py # 光机 RS485 协议与串口链路
|
||||
│ ├── selftest_rs485.py # 光机协议自检(无硬件回归)
|
||||
│ ├── build_exe.py # PyInstaller 打包 → release/
|
||||
│ ├── package_release.py # 组装发布 zip(exe+图案+说明)
|
||||
@@ -190,26 +204,29 @@ HDMI-Tool/
|
||||
│ ├── bw_*.png ×5 # 640x360 黑白系列
|
||||
│ └── solid_*.png ×10 # 800x600 纯色系列
|
||||
├── release/ # 构建与发布产物(机器生成区, 不手改)
|
||||
│ ├── HDMI-Tool.exe # 打包单文件版(改动跟随源码提交入库)
|
||||
│ ├── DLPilot.exe # 打包单文件版(改动跟随源码提交入库)
|
||||
│ ├── pattern_slots.json # exe 运行配置(栏位预置 + 串口记忆)
|
||||
│ ├── *.png / README.md # 附属文件平铺副本(由 package_release.py 从 assets/ 同步)
|
||||
│ └── *.zip # 发布包(不入库, 上传 Gitea Release)
|
||||
├── README.md # 本说明
|
||||
└── AGENTS.md # 面向 AI/Agent 的工程档案(结构/约定/构建规程)
|
||||
├── AGENTS.md # 面向 AI/Agent 的工程档案(结构/约定/构建规程)
|
||||
└── UI参考布局.svg/.png # 界面布局设计稿(app_gui.py 按此实现)
|
||||
```
|
||||
|
||||
从源码运行:`python src/hdmi_gui.py`,需 Python ≥3.10,标准库即可跑 EDID/投屏;
|
||||
光机控制需 pyserial(`py -m pip install pyserial`),自定义图片要 JPG/BMP/WebP 需可选 Pillow。
|
||||
从源码运行:`python src/hdmi_gui.py`,需 Python ≥3.10;EDID/命令行纯标准库;
|
||||
GUI 需 customtkinter(`py -m pip install customtkinter`);光机控制需 pyserial;
|
||||
自定义图片要 JPG/BMP/WebP 需可选 Pillow。
|
||||
|
||||
## 9. 构建与发布
|
||||
|
||||
```powershell
|
||||
cd src
|
||||
python selftest_rs485.py # 光机协议回归(改 dlpc_rs485.py 后必跑)
|
||||
python build_exe.py # 打包 → ../release/HDMI-Tool.exe(自动装 PyInstaller; 内置 pyserial)
|
||||
python build_exe.py # 打包 → ../release/DLPilot.exe(自动装 PyInstaller;
|
||||
# 内置 pyserial/customtkinter, CTk 主题数据自动收集)
|
||||
python build_exe.py --debug # 额外出一个带控制台的调试变体(--dump 可见输出)
|
||||
python package_release.py 0.2.0 # 组装 release/HDMI-Tool-v0.2.0-win64.zip
|
||||
python publish_gitea.py HDMI-Tool-v0.2.0-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN)
|
||||
python package_release.py 0.2.0 # 组装 release/DLPilot-v0.2.0-win64.zip
|
||||
python publish_gitea.py DLPilot-v0.2.0-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN)
|
||||
```
|
||||
|
||||
## 10. 注意事项 / FAQ
|
||||
@@ -220,6 +237,6 @@ python publish_gitea.py HDMI-Tool-v0.2.0-win64.zip --insecure # 上传 Gitea R
|
||||
- **exe 放只读目录会怎样**:自定义图片与 `pattern_slots.json` 写在 exe 旁边,只读目录会导致配置存不下;
|
||||
- **重新打包后图标没变**:Windows 图标缓存,`ie4uinit -show` 或改个名即可;
|
||||
- **多屏序号**:切分辨率前先确认显示器序号(GUI 下拉框与 `hdmi_source.bat -List` 输出一致);
|
||||
- **残留进程**:修改源码重新打包前先结束残留的 HDMI-Tool 进程(`build_exe.py` 会自动 taskkill);
|
||||
- **残留进程**:修改源码重新打包前先结束残留的 DLPilot 进程(`build_exe.py` 会自动 taskkill);
|
||||
- **改 logo**:改 `src/logos/logo_ph_orange.svg` → `python src/logos/make_ico.py` → 重新打包;
|
||||
- **配置互不影响**:exe 与源码脚本各自按所在目录保存 `pattern_slots.json`,两份安装互不干扰。
|
||||
|
||||
@@ -0,0 +1,887 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
DLPilot 主界面 — CustomTkinter 双栏仪表盘
|
||||
================================================================
|
||||
布局(DLPilot/UI参考布局.svg):
|
||||
左列 显示器选择 | 支持的模式 | EDID 信息 | 测试图案/纯色/自定义 | EDID 原始数据
|
||||
右列 串口选择与参数 | 串口数据(收发日志) | 预设与自定义命令 | 光强/定时快捷滑条
|
||||
默认深色外观, 顶栏可切换 Dark/Light/System。
|
||||
|
||||
本模块只管界面: 显示枚举在 display_win, EDID 解析在 edid_parse,
|
||||
投屏窗口在 patterns, 串口协议在 dlpc_rs485, 运行配置在 config。
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import ctypes
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import time
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog, messagebox
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
import config
|
||||
import display_win
|
||||
import dlpc_rs485
|
||||
import edid_parse
|
||||
import patterns
|
||||
|
||||
MONO = ('Consolas', 12)
|
||||
BOLD = ('Microsoft YaHei UI', 13, 'bold')
|
||||
TITLE_FONT = ('Microsoft YaHei UI', 18, 'bold')
|
||||
|
||||
APP_NAME = 'DLPilot'
|
||||
APP_VERSION = '0.3.0'
|
||||
REPO_URL = 'https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools'
|
||||
|
||||
|
||||
def get_icon_path():
|
||||
"""工具图标: 源码模式取 logos/ 下, 打包模式取 exe 内嵌资源(_MEIPASS)"""
|
||||
import sys
|
||||
if getattr(sys, 'frozen', False):
|
||||
p = os.path.join(getattr(sys, '_MEIPASS', ''), 'logo_dlpilot.ico')
|
||||
else:
|
||||
p = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
'logos', 'logo_dlpilot.ico')
|
||||
return p if os.path.exists(p) else None
|
||||
|
||||
|
||||
def _apply_dropdown_theme():
|
||||
"""CTk 默认下拉菜单是白底, 在深色界面里风格突兀; 改为随外观自适应的配色。
|
||||
在创建任何控件前调用, 对全部 CTkComboBox/菜单生效。"""
|
||||
ctk.ThemeManager.theme['DropdownMenu']['fg_color'] = ('#F2F2F2', '#2B2B2B')
|
||||
ctk.ThemeManager.theme['DropdownMenu']['hover_color'] = ('#D8D8D8', '#3C3C3C')
|
||||
ctk.ThemeManager.theme['DropdownMenu']['text_color'] = ('#1A1A1A', '#DADADA')
|
||||
|
||||
|
||||
def run_gui(auto_close=0):
|
||||
"""入口: 配置外观并启动主窗口"""
|
||||
ctk.set_appearance_mode('System')
|
||||
_apply_dropdown_theme()
|
||||
App(auto_close).mainloop()
|
||||
|
||||
|
||||
class App(ctk.CTk):
|
||||
def __init__(self, auto_close=0):
|
||||
super().__init__()
|
||||
self.title('DLPilot — 3D 光固化光机调试台 (EDID · 测试图案 · RS485 光机控制)')
|
||||
try: # 任务栏独立分组
|
||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID('hulk.DLPilot')
|
||||
except Exception:
|
||||
pass
|
||||
self.geometry('1440x920')
|
||||
self.minsize(1180, 800)
|
||||
ic = get_icon_path()
|
||||
if ic:
|
||||
try:
|
||||
self.iconbitmap(ic) # 窗口标题栏图标
|
||||
except Exception:
|
||||
pass
|
||||
self.displays = []
|
||||
self._disp_labels = []
|
||||
self._cur_label = ''
|
||||
self.slots, self.area = config.load_slots()
|
||||
self._modes = {} # 'WxH@Hz' -> DEVMODEW(当前所选屏)
|
||||
self._orig_modes = {} # device -> 切改前的原始 DEVMODEW
|
||||
# 光机控制链路: 回调发生在串口线程, 经队列投递回 GUI 主线程轮询
|
||||
self._dlpc_log_q = queue.Queue()
|
||||
self.dlpc = dlpc_rs485.DLPC485(self._dlpc_on_event, self._dlpc_on_status)
|
||||
self._dlpc_connected = False
|
||||
self._poll_job = None
|
||||
self._tl_order = [] # 温度查询应答顺序: 'DMD'/'LAMP' 先到先得
|
||||
self._build()
|
||||
self._load_displays()
|
||||
if dlpc_rs485.HAS_SERIAL:
|
||||
self._poll_dlpc()
|
||||
if auto_close > 0:
|
||||
self.after(auto_close * 1000, self.destroy)
|
||||
|
||||
# ============================================================
|
||||
# 布局
|
||||
# ============================================================
|
||||
def _set_appearance(self, mode):
|
||||
ctk.set_appearance_mode(mode)
|
||||
|
||||
def _card(self, parent, title, button=None, command=None, right_label=None,
|
||||
right_widget=None):
|
||||
"""卡片容器: 圆角边框 + 标题行(可带右侧小按钮/右对齐标签/自定义控件),
|
||||
返回 (卡片, 内容 frame)
|
||||
|
||||
卡片自身用 grid 参与列布局; 卡片内部(head/content/行控件)一律用 pack。
|
||||
"""
|
||||
card = ctk.CTkFrame(parent, corner_radius=10, border_width=1,
|
||||
border_color=('gray70', 'gray30'))
|
||||
head = ctk.CTkFrame(card, fg_color='transparent')
|
||||
head.pack(fill='x', padx=12, pady=(8, 0))
|
||||
ctk.CTkLabel(head, text=title, font=BOLD, anchor='w').pack(side='left', fill='y')
|
||||
if right_widget is not None: # 自定义控件(如 电流读数+查电流)
|
||||
right_widget.pack(side='right', fill='y')
|
||||
if right_label is not None:
|
||||
ctk.CTkLabel(head, textvariable=right_label,
|
||||
text_color='gray60').pack(side='right', fill='y')
|
||||
if button:
|
||||
ctk.CTkButton(head, text=button, width=92,
|
||||
command=command).pack(side='right', fill='y')
|
||||
content = ctk.CTkFrame(card, fg_color='transparent')
|
||||
content.pack(fill='both', expand=True, padx=12, pady=(2, 10))
|
||||
return card, content
|
||||
|
||||
def _build(self):
|
||||
top = ctk.CTkFrame(self, fg_color='transparent')
|
||||
top.pack(fill='x', padx=12, pady=(10, 2))
|
||||
ctk.CTkLabel(top, text='DLPilot', font=TITLE_FONT).pack(side='left')
|
||||
ctk.CTkLabel(top, text='EDID · 测试图案 · 光机控制',
|
||||
text_color='gray60').pack(side='left', padx=10, pady=(6, 0))
|
||||
seg = ctk.CTkSegmentedButton(top, values=['Dark', 'Light', 'System'],
|
||||
command=self._set_appearance)
|
||||
seg.set('System')
|
||||
seg.pack(side='right')
|
||||
ctk.CTkButton(top, text='关于', width=48, height=24,
|
||||
fg_color='transparent', border_width=1,
|
||||
border_color=('gray55', 'gray35'),
|
||||
text_color='gray60', hover_color=('gray82', 'gray22'),
|
||||
command=self._show_about).pack(side='right', padx=(0, 12))
|
||||
|
||||
body = ctk.CTkFrame(self, fg_color='transparent')
|
||||
body.pack(fill='both', expand=True, padx=12, pady=(4, 12))
|
||||
body.grid_columnconfigure(0, weight=1, uniform='col')
|
||||
body.grid_columnconfigure(1, weight=1, uniform='col')
|
||||
body.grid_rowconfigure(0, weight=1)
|
||||
left = ctk.CTkFrame(body, fg_color='transparent')
|
||||
left.grid(row=0, column=0, sticky='nsew')
|
||||
right = ctk.CTkFrame(body, fg_color='transparent')
|
||||
right.grid(row=0, column=1, sticky='nsew', padx=(10, 0))
|
||||
self._build_left(left)
|
||||
if dlpc_rs485.HAS_SERIAL:
|
||||
self._build_right(right)
|
||||
else:
|
||||
hint_card, hint = self._card(right, '光机控制不可用')
|
||||
hint_card.grid(row=0, column=0, sticky='nsew')
|
||||
ctk.CTkLabel(hint, justify='left', wraplength=380,
|
||||
text='未安装 pyserial。\n\n'
|
||||
'执行 py -m pip install pyserial 后重启本工具。'
|
||||
).pack(expand=True)
|
||||
|
||||
def _build_left(self, col):
|
||||
col.grid_rowconfigure(1, weight=2) # 支持的模式
|
||||
col.grid_rowconfigure(2, weight=1) # EDID 信息(压缩高度)
|
||||
col.grid_rowconfigure(4, weight=0) # EDID 原始数据(固定高, 压缩)
|
||||
col.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# -- 显示器选择区域 --
|
||||
self.lbl_cur_var = tk.StringVar(value='当前输出: —')
|
||||
card, c = self._card(col, '显示器选择区域', right_label=self.lbl_cur_var)
|
||||
card.grid(row=0, column=0, sticky='ew')
|
||||
row = ctk.CTkFrame(c, fg_color='transparent')
|
||||
row.pack(fill='x')
|
||||
self.cbo = ctk.CTkComboBox(row, state='readonly',
|
||||
command=lambda _=None: self._on_select())
|
||||
self.cbo.pack(side='left', fill='x', expand=True)
|
||||
ctk.CTkButton(row, text='刷新', width=64,
|
||||
command=self._load_displays).pack(side='left', padx=(6, 0))
|
||||
|
||||
# -- 支持的模式 --
|
||||
card, c = self._card(col,'支持的模式 (EDID 解算)')
|
||||
card.grid(row=1, column=0, sticky='nsew', pady=(10, 0))
|
||||
row = ctk.CTkFrame(c, fg_color='transparent')
|
||||
row.pack(fill='x')
|
||||
ctk.CTkLabel(row, text='分辨率:').pack(side='left')
|
||||
self.cbo_mode = ctk.CTkComboBox(row, width=180, state='readonly')
|
||||
self.cbo_mode.pack(side='left', padx=6)
|
||||
ctk.CTkButton(row, text='应用', width=64,
|
||||
command=self._apply_mode).pack(side='left')
|
||||
ctk.CTkButton(row, text='恢复', width=64,
|
||||
command=self._restore_mode).pack(side='left', padx=(6, 0))
|
||||
ctk.CTkLabel(row, text='临时切换, 重启还原',
|
||||
text_color='gray50').pack(side='left', padx=8)
|
||||
self.txt_modes = ctk.CTkTextbox(c, font=MONO, height=150)
|
||||
self.txt_modes.pack(fill='both', expand=True, pady=(8, 0))
|
||||
self.txt_modes.configure(state='disabled')
|
||||
|
||||
# -- EDID 信息 --
|
||||
card, c = self._card(col, 'EDID 信息')
|
||||
card.grid(row=2, column=0, sticky='nsew', pady=(10, 0))
|
||||
self.edid_scroll = ctk.CTkScrollableFrame(c, height=150)
|
||||
self.edid_scroll.pack(fill='both', expand=True)
|
||||
|
||||
# -- 测试图案 --
|
||||
card, c = self._card(col,'测试图案 — 黑底 1:1 居中, 按 ESC 退出')
|
||||
card.grid(row=3, column=0, sticky='ew', pady=(10, 0))
|
||||
row1 = ctk.CTkFrame(c, fg_color='transparent')
|
||||
row1.pack(fill='x')
|
||||
for key, name in patterns.PATTERN_NAMES.items():
|
||||
ctk.CTkButton(row1, text=name, width=78,
|
||||
command=lambda k=key: self._show_pattern(k)
|
||||
).pack(side='left', padx=(0, 6))
|
||||
ctk.CTkLabel(row1, text='测试卡区域:').pack(side='left', padx=(12, 4))
|
||||
self.area_var = tk.StringVar(value=self.area)
|
||||
seg_area = ctk.CTkSegmentedButton(row1, values=list(config.AREA_OPTIONS),
|
||||
command=self._save_area)
|
||||
seg_area.set(self.area)
|
||||
seg_area.pack(side='left')
|
||||
row2 = ctk.CTkFrame(c, fg_color='transparent')
|
||||
row2.pack(fill='x', pady=(6, 0))
|
||||
for ch, items in patterns.SOLID_ROWS:
|
||||
ctk.CTkLabel(row2, text=f'{ch}: 0x').pack(side='left')
|
||||
for label, color in items:
|
||||
ctk.CTkButton(row2, text=label, width=38, height=24,
|
||||
fg_color=color, hover_color=color,
|
||||
text_color='#000000' if color == '#00FF00' else '#FFFFFF',
|
||||
command=lambda cc=color: self._show_solid(cc)
|
||||
).pack(side='left', padx=3)
|
||||
# -- 图案栏位: 两排各 5 个, 等宽 --
|
||||
self.slot_buttons = []
|
||||
for row_idx, group in enumerate((range(0, 5), range(5, 10))):
|
||||
row = ctk.CTkFrame(c, fg_color='transparent')
|
||||
row.pack(fill='x', pady=(6, 0))
|
||||
for i in group:
|
||||
cell = ctk.CTkFrame(row, fg_color='transparent')
|
||||
cell.pack(side='left', padx=(0, 6))
|
||||
btn = ctk.CTkButton(cell, text=self.slots[i]['name'], width=84,
|
||||
command=lambda i=i: self._show_custom(i))
|
||||
btn.pack(side='left')
|
||||
ctk.CTkButton(cell, text='\uE713', width=26, height=24,
|
||||
font=('Segoe MDL2 Assets', 13),
|
||||
command=lambda i=i: self._configure_slot(i)
|
||||
).pack(side='left', padx=(3, 0))
|
||||
self.slot_buttons.append(btn)
|
||||
if row_idx == 0:
|
||||
ctk.CTkLabel(row, text='点击投图, 齿轮按钮配置图片',
|
||||
text_color='gray50').pack(side='left', padx=4)
|
||||
else:
|
||||
ctk.CTkLabel(row, text='校准图案(齿轮改图)',
|
||||
text_color='gray50').pack(side='left', padx=4)
|
||||
|
||||
# -- EDID 原始数据 --
|
||||
card, c = self._card(col, 'EDID 原始数据',
|
||||
button='逐字节解析', command=self._show_edid_fields)
|
||||
card.grid(row=4, column=0, sticky='ew', pady=(10, 0))
|
||||
self.txt_hex = ctk.CTkTextbox(c, font=MONO, wrap='none', height=130)
|
||||
self.txt_hex.pack(fill='x')
|
||||
self.txt_hex.configure(state='disabled')
|
||||
|
||||
def _build_right(self, col):
|
||||
col.grid_rowconfigure(1, weight=1) # 串口数据区域
|
||||
col.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# -- 串口选择与参数 --
|
||||
card, c = self._card(col,'串口 — RS485 → 板载 GD32 网关 → DLPC3421')
|
||||
card.grid(row=0, column=0, sticky='ew')
|
||||
row1 = ctk.CTkFrame(c, fg_color='transparent')
|
||||
row1.pack(fill='x')
|
||||
self._dlpc_ports = dlpc_rs485.DLPC485.list_ports_info()
|
||||
self.cbo_dlpc = ctk.CTkComboBox(row1, state='readonly',
|
||||
values=[lb for _d, lb in self._dlpc_ports])
|
||||
if config.SERIAL_CFG['port']:
|
||||
self._dlpc_pick(config.SERIAL_CFG['port'])
|
||||
self.cbo_dlpc.pack(side='left', fill='x', expand=True)
|
||||
ctk.CTkButton(row1, text='刷新', width=64,
|
||||
command=self._dlpc_refresh).pack(side='left', padx=(6, 0))
|
||||
self.btn_dlpc = ctk.CTkButton(row1, text='连接', width=72,
|
||||
command=self._dlpc_toggle)
|
||||
self.btn_dlpc.pack(side='left', padx=(6, 0))
|
||||
row2 = ctk.CTkFrame(c, fg_color='transparent')
|
||||
row2.pack(fill='x', pady=(6, 0))
|
||||
ctk.CTkLabel(row2, text='波特率:').pack(side='left')
|
||||
self.cbo_baud = ctk.CTkComboBox(row2, width=110,
|
||||
values=('115200', '57600', '38400', '9600'))
|
||||
self.cbo_baud.set(str(config.SERIAL_CFG['baud']))
|
||||
self.cbo_baud.pack(side='left', padx=6)
|
||||
ctk.CTkLabel(row2, text='数据位:').pack(side='left')
|
||||
self.cbo_databits = ctk.CTkComboBox(row2, width=62, values=('8', '7', '6', '5'))
|
||||
self.cbo_databits.set(str(config.SERIAL_CFG['bytesize']))
|
||||
self.cbo_databits.pack(side='left', padx=(6, 0))
|
||||
ctk.CTkLabel(row2, text='校验:').pack(side='left', padx=(10, 0))
|
||||
self.cbo_parity = ctk.CTkComboBox(row2, width=54, values=('N', 'E', 'O'))
|
||||
self.cbo_parity.set(str(config.SERIAL_CFG['parity']))
|
||||
self.cbo_parity.pack(side='left', padx=(6, 0))
|
||||
ctk.CTkLabel(row2, text='停止位:').pack(side='left', padx=(10, 0))
|
||||
self.cbo_stopbits = ctk.CTkComboBox(row2, width=54, values=('1', '2'))
|
||||
self.cbo_stopbits.set(str(config.SERIAL_CFG['stopbits']))
|
||||
self.cbo_stopbits.pack(side='left', padx=(6, 0))
|
||||
self.lbl_dlpc_st = ctk.CTkLabel(row2, text='未连接', text_color='gray60')
|
||||
self.lbl_dlpc_st.pack(side='left', padx=6)
|
||||
|
||||
# -- 串口数据区域 --
|
||||
card, c = self._card(col,'串口数据 — 收发日志(译码 + 原始 hex)',
|
||||
button='清空', command=self._dlpc_clear)
|
||||
card.grid(row=1, column=0, sticky='nsew', pady=(10, 0))
|
||||
self.txt_dlpc = ctk.CTkTextbox(c, font=MONO)
|
||||
self.txt_dlpc.pack(fill='both', expand=True)
|
||||
self.txt_dlpc.configure(state='disabled')
|
||||
self._dlpc_log('帧格式: D5 01 LEN <命令> CRC / B5 TYPE LEN <payload> CRC, '
|
||||
'累加和校验; 全部命令见 README"光机控制"')
|
||||
|
||||
# -- 光机命令 --
|
||||
# 标题行右侧: 电流实时读数 + 查电流按钮(okR..G..B.. 应答解析)
|
||||
cur_frame = ctk.CTkFrame(self, fg_color='transparent')
|
||||
self.var_current = tk.StringVar(value='电流: —')
|
||||
ctk.CTkLabel(cur_frame, textvariable=self.var_current, width=168,
|
||||
anchor='e', text_color='#3B8ED0').pack(side='left')
|
||||
ctk.CTkButton(cur_frame, text='查电流', width=64, height=24,
|
||||
command=self._query_current).pack(side='left', padx=(6, 0))
|
||||
card, c = self._card(col,'光机命令 — 预设 / 自定义 / TEC',
|
||||
right_widget=cur_frame)
|
||||
card.grid(row=2, column=0, sticky='ew', pady=(10, 0))
|
||||
row1 = ctk.CTkFrame(c, fg_color='transparent')
|
||||
row1.pack(fill='x')
|
||||
for name, cmd in (('开光机', 'M730S0'), ('关光机', 'M730S1'),
|
||||
('查版本', 'M999'), ('软件重启', 'M888'),
|
||||
('开TEC', 'M7S0'), ('关TEC', 'M7S1'), ('TEC33.0°C', 'M8S330')):
|
||||
ctk.CTkButton(row1, text=name, width=64,
|
||||
command=lambda cc=cmd: self._dlpc_send(cc)
|
||||
).pack(side='left', fill='x', expand=True, padx=(0, 4))
|
||||
row2 = ctk.CTkFrame(c, fg_color='transparent')
|
||||
row2.pack(fill='x', pady=(6, 0))
|
||||
row2.grid_columnconfigure(0, weight=6) # 左: 预设(约 6 份)
|
||||
row2.grid_columnconfigure(1, weight=4) # 右: 温度查询+实时显示(约 4 份)
|
||||
lf2 = ctk.CTkFrame(row2, fg_color='transparent')
|
||||
lf2.grid(row=0, column=0, sticky='ew')
|
||||
ctk.CTkLabel(lf2, text='预设:', width=52, anchor='w').pack(side='left')
|
||||
self._dlpc_presets = [(n, cmd) for n, cmd, _ in dlpc_rs485.PRESETS]
|
||||
self.cbo_dlpc_cmd = ctk.CTkComboBox(
|
||||
lf2, state='readonly',
|
||||
values=[f'{n} {cmd}' for n, cmd in self._dlpc_presets])
|
||||
self.cbo_dlpc_cmd.set(self._dlpc_presets[0][0] + ' ' + self._dlpc_presets[0][1])
|
||||
self.cbo_dlpc_cmd.pack(side='left', fill='x', expand=True, padx=6)
|
||||
ctk.CTkButton(lf2, text='发送', width=64,
|
||||
command=self._dlpc_send_preset).pack(side='left')
|
||||
rf2 = ctk.CTkFrame(row2, fg_color='transparent')
|
||||
rf2.grid(row=0, column=1, sticky='ew', padx=(8, 0))
|
||||
ctk.CTkButton(rf2, text='查DMD温度', width=90,
|
||||
command=self._query_dmd).pack(side='left')
|
||||
self.var_dmd = tk.StringVar(value='DMD: —')
|
||||
ctk.CTkLabel(rf2, textvariable=self.var_dmd, width=110, height=28,
|
||||
corner_radius=6, fg_color=('#F2F2F2', '#181818'),
|
||||
anchor='w').pack(side='left', fill='x', expand=True, padx=(6, 0))
|
||||
row3 = ctk.CTkFrame(c, fg_color='transparent')
|
||||
row3.pack(fill='x', pady=(6, 0))
|
||||
row3.grid_columnconfigure(0, weight=6)
|
||||
row3.grid_columnconfigure(1, weight=4)
|
||||
lf3 = ctk.CTkFrame(row3, fg_color='transparent')
|
||||
lf3.grid(row=0, column=0, sticky='ew')
|
||||
ctk.CTkLabel(lf3, text='自定义:', width=52, anchor='w').pack(side='left')
|
||||
self.var_custom_cmd = tk.StringVar()
|
||||
ent = ctk.CTkEntry(lf3, textvariable=self.var_custom_cmd,
|
||||
placeholder_text='如 M730S0T2000')
|
||||
ent.pack(side='left', fill='x', expand=True, padx=6)
|
||||
ent.bind('<Return>', lambda _e: self._dlpc_send_custom())
|
||||
ctk.CTkButton(lf3, text='发送', width=64,
|
||||
command=self._dlpc_send_custom).pack(side='left')
|
||||
rf3 = ctk.CTkFrame(row3, fg_color='transparent')
|
||||
rf3.grid(row=0, column=1, sticky='ew', padx=(8, 0))
|
||||
ctk.CTkButton(rf3, text='查LAMP温度', width=90,
|
||||
command=self._query_lamp).pack(side='left')
|
||||
self.var_lamp = tk.StringVar(value='LAMP: —')
|
||||
ctk.CTkLabel(rf3, textvariable=self.var_lamp, width=110, height=28,
|
||||
corner_radius=6, fg_color=('#F2F2F2', '#181818'),
|
||||
anchor='w').pack(side='left', fill='x', expand=True, padx=(6, 0))
|
||||
|
||||
# -- 快捷滑条 --
|
||||
card, c = self._card(col,'快捷滑条 — 光强 / 定时投光 / TEC 温度')
|
||||
card.grid(row=3, column=0, sticky='ew', pady=(10, 0))
|
||||
rowa = ctk.CTkFrame(c, fg_color='transparent')
|
||||
rowa.pack(fill='x')
|
||||
ctk.CTkLabel(rowa, text='光强(54-800):').pack(side='left')
|
||||
self.var_light = tk.StringVar(value='100')
|
||||
sld = ctk.CTkSlider(rowa, from_=54, to=800, number_of_steps=149,
|
||||
command=lambda v: self.var_light.set(str(int(round(float(v))))))
|
||||
sld.set(100)
|
||||
sld.pack(side='left', fill='x', expand=True, padx=8)
|
||||
self.sld_light = sld
|
||||
self.ent_light = ctk.CTkEntry(rowa, width=56, justify='center',
|
||||
textvariable=self.var_light)
|
||||
self.ent_light.pack(side='left')
|
||||
self.ent_light.bind('<Return>', self._light_entry_apply)
|
||||
ctk.CTkButton(rowa, text='下发电流值', width=110,
|
||||
command=self._dlpc_send_light).pack(side='left', padx=(6, 0))
|
||||
rowb = ctk.CTkFrame(c, fg_color='transparent')
|
||||
rowb.pack(fill='x', pady=(8, 0))
|
||||
ctk.CTkLabel(rowb, text='定时投光(毫秒):').pack(side='left')
|
||||
self.var_timer = tk.StringVar(value='2000')
|
||||
sld_t = ctk.CTkSlider(rowb, from_=0, to=32700, number_of_steps=327,
|
||||
command=lambda v: self.var_timer.set(str(int(round(float(v))))))
|
||||
sld_t.set(2000)
|
||||
sld_t.pack(side='left', fill='x', expand=True, padx=8)
|
||||
self.sld_timer = sld_t
|
||||
self.ent_timer = ctk.CTkEntry(rowb, width=56, justify='center',
|
||||
textvariable=self.var_timer)
|
||||
self.ent_timer.pack(side='left')
|
||||
self.ent_timer.bind('<Return>', self._timer_entry_apply)
|
||||
ctk.CTkButton(rowb, text='开光机(定时)', width=110,
|
||||
command=self._dlpc_send_timer).pack(side='left', padx=(6, 0))
|
||||
rowc = ctk.CTkFrame(c, fg_color='transparent')
|
||||
rowc.pack(fill='x', pady=(8, 0))
|
||||
ctk.CTkLabel(rowc, text='TEC凝露点(0.1°C):').pack(side='left')
|
||||
self.var_tec = tk.StringVar(value='330')
|
||||
sld_c = ctk.CTkSlider(rowc, from_=200, to=350, number_of_steps=150,
|
||||
command=lambda v: self.var_tec.set(str(int(round(float(v))))))
|
||||
sld_c.set(330)
|
||||
sld_c.pack(side='left', fill='x', expand=True, padx=8)
|
||||
self.sld_tec = sld_c
|
||||
self.ent_tec = ctk.CTkEntry(rowc, width=56, justify='center',
|
||||
textvariable=self.var_tec)
|
||||
self.ent_tec.pack(side='left')
|
||||
self.ent_tec.bind('<Return>', self._tec_entry_apply)
|
||||
ctk.CTkButton(rowc, text='下发温度值', width=110,
|
||||
command=self._dlpc_send_tec).pack(side='left', padx=(6, 0))
|
||||
|
||||
# ============================================================
|
||||
# 显示器 / 分辨率 / EDID
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def _ro(txt):
|
||||
txt.configure(state='disabled')
|
||||
|
||||
def _set_text(self, txt, content):
|
||||
txt.configure(state='normal')
|
||||
txt.delete('1.0', 'end')
|
||||
txt.insert('1.0', content)
|
||||
txt.configure(state='disabled')
|
||||
|
||||
def _cur_display(self):
|
||||
if self.cbo.get() in self._disp_labels:
|
||||
return self.displays[self._disp_labels.index(self.cbo.get())]
|
||||
return None
|
||||
|
||||
def _load_displays(self):
|
||||
prev = self._cur_label
|
||||
self.displays = display_win.get_displays()
|
||||
self._disp_labels = [f"{d['device']} {d['adapter']} [{d['mon_name']}]"
|
||||
for d in self.displays]
|
||||
self.cbo.configure(values=self._disp_labels)
|
||||
if not self.displays:
|
||||
self._cur_label = ''
|
||||
self.lbl_cur_var.set('未找到活动显示器')
|
||||
return
|
||||
idx = self._disp_labels.index(prev) if prev in self._disp_labels else 0
|
||||
self._cur_label = self._disp_labels[idx]
|
||||
self.cbo.set(self._cur_label)
|
||||
self._on_select()
|
||||
|
||||
def _on_select(self, _evt=None):
|
||||
if self.cbo.get() not in self._disp_labels:
|
||||
return
|
||||
self._cur_label = self.cbo.get()
|
||||
d = self._cur_display()
|
||||
self.lbl_cur_var.set(f"当前输出: {d['cur_mode']}")
|
||||
keys, modes = display_win.enum_modes(d['device'])
|
||||
self._modes = modes
|
||||
self.cbo_mode.configure(values=keys)
|
||||
if d['cur_mode'] in modes:
|
||||
self.cbo_mode.set(d['cur_mode'])
|
||||
elif keys:
|
||||
self.cbo_mode.set(keys[0])
|
||||
self._clear_edid_rows()
|
||||
if not d['edid']:
|
||||
self._add_edid_row('EDID', '读取失败(注册表无 EDID 数据)')
|
||||
self._set_text(self.txt_modes, '')
|
||||
self._set_text(self.txt_hex, '')
|
||||
return
|
||||
e = edid_parse.parse_edid(d['edid'])
|
||||
for k, v in edid_parse.edid_info_lines(e):
|
||||
self._add_edid_row(k, v)
|
||||
lines = []
|
||||
for k, t in enumerate(e['dtds']):
|
||||
src = 'DTD 首选' if k == 0 else 'DTD'
|
||||
lines.append(f"{t['hact']}x{t['vact']} @{t['refresh']:g}Hz "
|
||||
f"{t['clk_khz'] / 1000:.2f}MHz {src}")
|
||||
for m in e['cea_video']:
|
||||
lines.append(f'{m} [CEA-VIC]')
|
||||
for m in e['est']:
|
||||
lines.append(f'{m} [已建立时序]')
|
||||
for m in e['std']:
|
||||
lines.append(f'{m} [标准时序]')
|
||||
self._set_text(self.txt_modes, '\n'.join(lines))
|
||||
self._set_text(self.txt_hex, edid_parse.hex_dump(d['edid']))
|
||||
|
||||
def _clear_edid_rows(self):
|
||||
for w in self.edid_scroll.winfo_children():
|
||||
w.destroy()
|
||||
|
||||
def _add_edid_row(self, k, v):
|
||||
row = ctk.CTkFrame(self.edid_scroll, fg_color='transparent')
|
||||
row.pack(fill='x', pady=1)
|
||||
ctk.CTkLabel(row, text=k, width=132, anchor='w',
|
||||
text_color='gray60').pack(side='left')
|
||||
ctk.CTkLabel(row, text=v, anchor='w').pack(side='left',
|
||||
fill='x', expand=True)
|
||||
|
||||
def _apply_mode(self):
|
||||
d = self._cur_display()
|
||||
if d is None or self.cbo_mode.get() not in self._modes:
|
||||
return
|
||||
dev = d['device']
|
||||
if dev not in self._orig_modes: # 记住首次切改前的原始模式, 供"恢复"用
|
||||
cur = display_win.DEVMODEW()
|
||||
cur.dmSize = ctypes.sizeof(cur)
|
||||
if display_win.user32.EnumDisplaySettingsW(
|
||||
dev, display_win.ENUM_CURRENT_SETTINGS, ctypes.byref(cur)):
|
||||
self._orig_modes[dev] = cur
|
||||
ok, r = display_win.apply_mode(dev, self._modes[self.cbo_mode.get()])
|
||||
if not ok:
|
||||
messagebox.showerror('切换失败', f'ChangeDisplaySettingsEx 返回码 {r}')
|
||||
return
|
||||
self.after(500, self._load_displays) # 等模式生效后刷新矩形/EDID 面板
|
||||
|
||||
def _restore_mode(self):
|
||||
d = self._cur_display()
|
||||
if d is None:
|
||||
return
|
||||
dm = self._orig_modes.pop(d['device'], None)
|
||||
if dm is None:
|
||||
messagebox.showinfo('无可恢复', '该显示器没有记录切改前的模式')
|
||||
return
|
||||
display_win.apply_mode(d['device'], dm)
|
||||
self.after(500, self._load_displays)
|
||||
|
||||
def _show_pattern(self, kind):
|
||||
d = self._cur_display()
|
||||
if d and d['rect']:
|
||||
patterns.show_pattern(self, d['rect'], kind, area=self.area_var.get())
|
||||
|
||||
def _show_solid(self, color):
|
||||
d = self._cur_display()
|
||||
if d and d['rect']:
|
||||
patterns.show_pattern(self, d['rect'], color, area=self.area_var.get())
|
||||
|
||||
def _save_area(self, value):
|
||||
self.area_var.set(value)
|
||||
self.area = value
|
||||
config.save_slots(self.slots, self.area)
|
||||
|
||||
def _show_custom(self, idx):
|
||||
slot = self.slots[idx]
|
||||
if not slot['file']:
|
||||
messagebox.showinfo('未配置图片', f'请先点击齿轮按钮为"{slot["name"]}"配置图片。')
|
||||
return
|
||||
path = os.path.join(config.BASE_DIR, slot['file'])
|
||||
if not os.path.exists(path):
|
||||
messagebox.showerror('图片不存在', path)
|
||||
return
|
||||
d = self._cur_display()
|
||||
if d and d['rect']:
|
||||
patterns.show_pattern_image(self, d['rect'], path, area=self.area_var.get())
|
||||
|
||||
def _configure_slot(self, idx):
|
||||
dlg = ctk.CTkToplevel(self)
|
||||
dlg.title(f'配置自定义栏位 {idx + 1}')
|
||||
dlg.resizable(False, False)
|
||||
dlg.grab_set()
|
||||
dlg.after(150, dlg.grab_set) # CTkToplevel 焦点时序
|
||||
|
||||
name_var = tk.StringVar(value=self.slots[idx]['name'])
|
||||
file_var = tk.StringVar(value=self.slots[idx]['file'] or '(未设置)')
|
||||
|
||||
body = ctk.CTkFrame(dlg, fg_color='transparent')
|
||||
body.pack(padx=16, pady=14)
|
||||
ctk.CTkLabel(body, text='按钮名称:').grid(row=0, column=0, sticky='w', pady=4)
|
||||
ctk.CTkEntry(body, textvariable=name_var, width=240
|
||||
).grid(row=0, column=1, columnspan=2, sticky='w', padx=6)
|
||||
ctk.CTkLabel(body, text='图片文件:').grid(row=1, column=0, sticky='w', pady=4)
|
||||
ctk.CTkLabel(body, textvariable=file_var, text_color='#3B8ED0'
|
||||
).grid(row=1, column=1, columnspan=2, sticky='w', padx=6)
|
||||
if not config.HAS_PIL:
|
||||
ctk.CTkLabel(body, text='(未安装 Pillow, 仅支持 PNG/GIF; '
|
||||
'安装 pillow 后可用 JPG/BMP/WebP)',
|
||||
text_color='#E07A3F').grid(row=2, column=0, columnspan=3,
|
||||
sticky='w')
|
||||
|
||||
def choose():
|
||||
exts = ' '.join(f'*{e}' for e in config.IMG_EXTS)
|
||||
path = filedialog.askopenfilename(parent=dlg, title='选择图片',
|
||||
filetypes=[('图片文件', exts),
|
||||
('所有文件', '*.*')])
|
||||
if path:
|
||||
try:
|
||||
file_var.set(config.copy_image_into_script_dir(path))
|
||||
except OSError as ex:
|
||||
messagebox.showerror('复制图片失败', str(ex), parent=dlg)
|
||||
|
||||
def save():
|
||||
name = name_var.get().strip() or f'自定义{idx + 1}'
|
||||
fname = file_var.get()
|
||||
self.slots[idx] = {'name': name,
|
||||
'file': None if fname.startswith('(') else fname}
|
||||
config.save_slots(self.slots, self.area_var.get())
|
||||
self.slot_buttons[idx].configure(text=name)
|
||||
dlg.destroy()
|
||||
|
||||
btns = ctk.CTkFrame(body, fg_color='transparent')
|
||||
btns.grid(row=3 if not config.HAS_PIL else 2, column=0,
|
||||
columnspan=3, sticky='w', pady=(10, 0))
|
||||
ctk.CTkButton(btns, text='选择图片...', width=110,
|
||||
command=choose).pack(side='left', padx=(0, 6))
|
||||
ctk.CTkButton(btns, text='清除图片', width=90,
|
||||
command=lambda: file_var.set('(未设置)')).pack(side='left', padx=(0, 6))
|
||||
ctk.CTkButton(btns, text='保存', width=70, command=save).pack(side='left', padx=(0, 6))
|
||||
ctk.CTkButton(btns, text='取消', width=70, fg_color='gray40',
|
||||
hover_color='gray25', command=dlg.destroy).pack(side='left')
|
||||
|
||||
def _show_about(self):
|
||||
dlg = ctk.CTkToplevel(self)
|
||||
dlg.title(f'关于 {APP_NAME}')
|
||||
dlg.resizable(False, False)
|
||||
dlg.after(150, dlg.grab_set())
|
||||
box = ctk.CTkFrame(dlg, fg_color='transparent')
|
||||
box.pack(padx=26, pady=20)
|
||||
ctk.CTkLabel(box, text=APP_NAME, font=TITLE_FONT, anchor='w').pack(anchor='w')
|
||||
ctk.CTkLabel(box, text=f'v{APP_VERSION} — 3D 光固化光机调试台',
|
||||
text_color='gray60', anchor='w').pack(anchor='w', pady=(0, 12))
|
||||
for line in ('EDID 查看 / 逐字节解析 / 分辨率切换',
|
||||
'测试图案与校准图全屏投屏',
|
||||
'RS485 光机控制: 开关光机 / 光强 / 定时 / 温度 / TEC',
|
||||
'配套硬件: DLPilot(HDMI-0.16" DLP 转接板)',
|
||||
'作者: Hulk'):
|
||||
ctk.CTkLabel(box, text='· ' + line, anchor='w').pack(anchor='w', pady=1)
|
||||
ctk.CTkLabel(box, text='仓库: ' + REPO_URL, anchor='w',
|
||||
text_color='#3B8ED0').pack(anchor='w', pady=(12, 0))
|
||||
ctk.CTkButton(box, text='关闭', width=84,
|
||||
command=dlg.destroy).pack(anchor='e', pady=(14, 0))
|
||||
|
||||
def _show_edid_fields(self):
|
||||
d = self._cur_display()
|
||||
if d is None:
|
||||
return
|
||||
if not d['edid']:
|
||||
messagebox.showinfo('无 EDID', '该显示器注册表中没有 EDID 数据, 无法逐字节解析')
|
||||
return
|
||||
dlg = ctk.CTkToplevel(self)
|
||||
dlg.title(f'EDID 逐字节解析 — {d["device"]} [{d["mon_name"]}]')
|
||||
dlg.geometry('1120x700')
|
||||
dlg.after(150, dlg.grab_set())
|
||||
txt = ctk.CTkTextbox(dlg, font=MONO, wrap='char')
|
||||
txt.pack(fill='both', expand=True, padx=8, pady=(8, 4))
|
||||
try: # 深底配色(个别 CTk 版本不支持 tag 时退化为单色)
|
||||
txt.tag_config('field', foreground='#E8E8E8')
|
||||
txt.tag_config('raw', foreground='#7F7F7F')
|
||||
txt.tag_config('note', foreground='#9FB8CC')
|
||||
except Exception:
|
||||
pass
|
||||
for off, hx, meaning, val, note in edid_parse.edid_field_rows(d['edid']):
|
||||
try:
|
||||
txt.insert('end', f'{off} {meaning} {val}\n', 'field')
|
||||
if hx:
|
||||
txt.insert('end', f' 原始: {hx}\n', 'raw')
|
||||
if note:
|
||||
txt.insert('end', f' {note}\n', 'note')
|
||||
except tk.TclError: # 个别 CTk 版本不支持 tag 插入, 退化为单色
|
||||
txt.insert('end', f'{off} {meaning} {val}\n')
|
||||
self._ro(txt)
|
||||
ctk.CTkButton(dlg, text='关闭', width=90, command=dlg.destroy).pack(pady=(0, 8))
|
||||
|
||||
# ============================================================
|
||||
# 光机控制(RS485)
|
||||
# ============================================================
|
||||
def _dlpc_on_event(self, line):
|
||||
# 温度查询应答: okTL:<0.1°C>, 依发送顺序归入 DMD/LAMP 实时显示框
|
||||
m = re.search(r'← okTL:(-?\d+)', line)
|
||||
if m and self._tl_order:
|
||||
which = self._tl_order.pop(0)
|
||||
try:
|
||||
text = f'{which}: {int(m.group(1)) / 10:.1f}°C'
|
||||
except ValueError:
|
||||
text = f'{which}: {m.group(1)}'
|
||||
self._dlpc_log_q.put(('temp', text))
|
||||
# 电流查询应答: okR<n>G<n>B<n> -> 标题行实时读数
|
||||
m = re.search(r'← okR(\d+)G(\d+)B(\d+)', line)
|
||||
if m:
|
||||
self._dlpc_log_q.put(('current', f'电流 R:{m.group(1)} '
|
||||
f'G:{m.group(2)} B:{m.group(3)}'))
|
||||
self._dlpc_log_q.put(('event', line))
|
||||
|
||||
def _query_current(self):
|
||||
self._dlpc_send('M732')
|
||||
|
||||
def _query_dmd(self):
|
||||
self._tl_order.append('DMD')
|
||||
self._dlpc_send('M201')
|
||||
|
||||
def _query_lamp(self):
|
||||
self._tl_order.append('LAMP')
|
||||
self._dlpc_send('M202')
|
||||
|
||||
def _dlpc_on_status(self, msg):
|
||||
self._dlpc_log_q.put(('status', msg))
|
||||
|
||||
def _poll_dlpc(self):
|
||||
"""串口线程 -> GUI 主线程: 80ms 轮询日志队列并同步连接状态"""
|
||||
try:
|
||||
while True:
|
||||
kind, text = self._dlpc_log_q.get_nowait()
|
||||
if kind == 'status':
|
||||
if text.startswith('已连接'):
|
||||
color = '#2FA574'
|
||||
elif ('失败' in text or '丢失' in text or '未' in text
|
||||
or '非法' in text or '丢弃' in text):
|
||||
color = '#E07A3F'
|
||||
else:
|
||||
color = 'gray60'
|
||||
self.lbl_dlpc_st.configure(text=text, text_color=color)
|
||||
elif kind == 'temp':
|
||||
if text.startswith('DMD'):
|
||||
self.var_dmd.set(text)
|
||||
else:
|
||||
self.var_lamp.set(text)
|
||||
elif kind == 'current':
|
||||
self.var_current.set(text)
|
||||
self._dlpc_log(text)
|
||||
except queue.Empty:
|
||||
pass
|
||||
connected = self.dlpc.is_connected()
|
||||
if connected != self._dlpc_connected:
|
||||
self._dlpc_connected = connected
|
||||
self.btn_dlpc.configure(text='断开' if connected else '连接')
|
||||
self._poll_job = self.after(80, self._poll_dlpc)
|
||||
|
||||
def _dlpc_log(self, line):
|
||||
self.txt_dlpc.configure(state='normal')
|
||||
self.txt_dlpc.insert('end', time.strftime('%H:%M:%S ') + line + '\n')
|
||||
if int(self.txt_dlpc.index('end-1c').split('.')[0]) > 500:
|
||||
self.txt_dlpc.delete('1.0', '2.0') # 只保留最近约 500 行
|
||||
self.txt_dlpc.see('end')
|
||||
self.txt_dlpc.configure(state='disabled')
|
||||
|
||||
def _dlpc_clear(self):
|
||||
self._set_text(self.txt_dlpc, '')
|
||||
|
||||
def _dlpc_refresh(self):
|
||||
self._dlpc_ports = dlpc_rs485.DLPC485.list_ports_info()
|
||||
self.cbo_dlpc.configure(values=[lb for _d, lb in self._dlpc_ports])
|
||||
devices = [d for d, _l in self._dlpc_ports]
|
||||
if config.SERIAL_CFG['port'] in devices:
|
||||
self._dlpc_pick(config.SERIAL_CFG['port'])
|
||||
elif devices:
|
||||
self._dlpc_pick(devices[0])
|
||||
|
||||
def _dlpc_pick(self, device):
|
||||
"""按 COM 口名选中下拉项(下拉显示的是带描述的标签)"""
|
||||
for i, (d, label) in enumerate(self._dlpc_ports):
|
||||
if d == device:
|
||||
self.cbo_dlpc.set(label)
|
||||
return
|
||||
|
||||
def _dlpc_selected_port(self):
|
||||
label = self.cbo_dlpc.get()
|
||||
for d, lb in self._dlpc_ports:
|
||||
if lb == label:
|
||||
return d
|
||||
return label.strip()
|
||||
|
||||
def _dlpc_toggle(self):
|
||||
if self.dlpc.is_connected():
|
||||
self.dlpc.close()
|
||||
return
|
||||
port = self._dlpc_selected_port()
|
||||
if not port:
|
||||
self.lbl_dlpc_st.configure(text='未选择串口', text_color='#E07A3F')
|
||||
return
|
||||
try:
|
||||
baud = int(self.cbo_baud.get())
|
||||
bytesize = int(self.cbo_databits.get())
|
||||
stopbits = int(self.cbo_stopbits.get())
|
||||
except ValueError:
|
||||
self.lbl_dlpc_st.configure(text='串口参数非法', text_color='#E07A3F')
|
||||
return
|
||||
parity = self.cbo_parity.get().strip().upper()[:1] or 'N'
|
||||
config.SERIAL_CFG.update(port=port, baud=baud, bytesize=bytesize,
|
||||
parity=parity, stopbits=stopbits)
|
||||
config.save_slots(self.slots, self.area_var.get()) # 记住本次串口
|
||||
self.dlpc.open(port, baud, bytesize=bytesize, parity=parity, stopbits=stopbits)
|
||||
|
||||
def _dlpc_send(self, cmd):
|
||||
if not self.dlpc.is_connected():
|
||||
self.lbl_dlpc_st.configure(text='未连接串口', text_color='#E07A3F')
|
||||
return
|
||||
self.dlpc.send(cmd)
|
||||
|
||||
def _dlpc_send_preset(self):
|
||||
label = self.cbo_dlpc_cmd.get()
|
||||
for name, cmd in self._dlpc_presets:
|
||||
if label.startswith(name + ' '):
|
||||
self._dlpc_send(cmd)
|
||||
return
|
||||
|
||||
def _dlpc_send_custom(self):
|
||||
cmd = self.var_custom_cmd.get().strip()
|
||||
if cmd:
|
||||
self._dlpc_send(cmd)
|
||||
|
||||
def _light_entry_apply(self, _evt=None):
|
||||
"""滑条数值输入框回车: 校验并限幅 54-800, 同步回滑条"""
|
||||
try:
|
||||
v = int(self.var_light.get())
|
||||
except ValueError:
|
||||
self.var_light.set(str(int(float(self.sld_light.get()))))
|
||||
return
|
||||
v = max(54, min(800, v))
|
||||
self.sld_light.set(v)
|
||||
self.var_light.set(str(v))
|
||||
|
||||
def _timer_entry_apply(self, _evt=None):
|
||||
"""定时滑条数值输入框回车: 校验并限幅 0-32700(ms), 同步回滑条"""
|
||||
try:
|
||||
v = int(self.var_timer.get())
|
||||
except ValueError:
|
||||
self.var_timer.set(str(int(float(self.sld_timer.get()))))
|
||||
return
|
||||
v = max(0, min(32700, v))
|
||||
self.sld_timer.set(v)
|
||||
self.var_timer.set(str(v))
|
||||
|
||||
def _dlpc_send_light(self):
|
||||
try:
|
||||
v = int(self.var_light.get())
|
||||
except ValueError:
|
||||
messagebox.showinfo('光强非法', '光强需为 54-800 的整数')
|
||||
return
|
||||
if not 54 <= v <= 800:
|
||||
messagebox.showinfo('光强越界', '光强范围 54-800(越界固件会回 err:OL)')
|
||||
return
|
||||
self._dlpc_send(f'M731S{v}')
|
||||
|
||||
def _dlpc_send_timer(self):
|
||||
try:
|
||||
v = int(self.var_timer.get())
|
||||
except ValueError:
|
||||
messagebox.showinfo('定时非法', '定时投光需为 0-32700 的整数毫秒(0=不限时)')
|
||||
return
|
||||
self._dlpc_send('M730S0' if v <= 0 else f'M730S0T{v}')
|
||||
|
||||
def _tec_entry_apply(self, _evt=None):
|
||||
"""TEC 温度输入框回车: 校验并限幅 200-350(0.1°C), 同步回滑条"""
|
||||
try:
|
||||
v = int(self.var_tec.get())
|
||||
except ValueError:
|
||||
self.var_tec.set(str(int(float(self.sld_tec.get()))))
|
||||
return
|
||||
v = max(200, min(350, v))
|
||||
self.sld_tec.set(v)
|
||||
self.var_tec.set(str(v))
|
||||
|
||||
def _dlpc_send_tec(self):
|
||||
try:
|
||||
v = int(self.var_tec.get())
|
||||
except ValueError:
|
||||
messagebox.showinfo('TEC 温度非法', 'TEC 凝露点需为 200-350 的整数(0.1°C)')
|
||||
return
|
||||
if not 200 <= v <= 350:
|
||||
messagebox.showinfo('TEC 温度越界',
|
||||
'TEC 凝露点范围 200-350(0.1°C), 越界固件会回 err:OL')
|
||||
return
|
||||
self._dlpc_send(f'M8S{v}')
|
||||
|
||||
def destroy(self):
|
||||
try: # 取消待触发的轮询回调, 避免销毁后 Tcl 报错
|
||||
if self._poll_job:
|
||||
self.after_cancel(self._poll_job)
|
||||
except Exception:
|
||||
pass
|
||||
try: # 两种退出路径(关窗/--seconds 自动关)都收掉串口线程
|
||||
self.dlpc.close()
|
||||
except Exception:
|
||||
pass
|
||||
super().destroy()
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
把 hdmi_gui.py 打包成单文件 exe(工具名: HDMI-Tool)
|
||||
把 hdmi_gui.py 打包成单文件 exe(工具名: DLPilot)
|
||||
================================
|
||||
使用 PyInstaller --onefile --noconsole: Python 解释器 + 标准库 + tkinter
|
||||
全部打进一个 exe, 目标机器无需安装任何依赖。
|
||||
@@ -10,7 +10,7 @@
|
||||
python build_exe.py 自动安装 PyInstaller(如缺)并打包
|
||||
python build_exe.py --debug 额外打一个带控制台的变体(--dump 可用)
|
||||
|
||||
产物: release/HDMI-Tool.exe(及 --debug 时的 HDMI-Tool-console.exe)
|
||||
产物: release/DLPilot.exe(及 --debug 时的 DLPilot-console.exe)
|
||||
首次运行 onefile exe 会先解压到临时目录, 启动比脚本慢一两秒, 属正常现象。
|
||||
"""
|
||||
|
||||
@@ -21,7 +21,7 @@ import sys
|
||||
# 本脚本位于 src/, 打包产物输出到工具根的 release/(与 pattern_slots.json 同目录)
|
||||
SRC = os.path.dirname(os.path.abspath(__file__))
|
||||
BASE = os.path.dirname(SRC)
|
||||
ICO = os.path.join(SRC, 'logos', 'logo_ph_orange.ico')
|
||||
ICO = os.path.join(SRC, 'logos', 'logo_dlpilot.ico')
|
||||
|
||||
|
||||
def ensure_pyinstaller():
|
||||
@@ -34,6 +34,16 @@ def ensure_pyinstaller():
|
||||
subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'pyinstaller'])
|
||||
|
||||
|
||||
def extra_collect_args():
|
||||
"""CustomTkinter 的主题/字体等数据文件必须显式收集, 否则打包后 GUI 启动报错"""
|
||||
try:
|
||||
import customtkinter # noqa: F401
|
||||
return ['--collect-all', 'customtkinter']
|
||||
except ImportError:
|
||||
print('警告: 未安装 customtkinter, 打包产物将无法启动 GUI!')
|
||||
return []
|
||||
|
||||
|
||||
def remove_stale(exe_path):
|
||||
"""删除旧 exe; 若被占用(上次验证的进程还在), 先结束同名进程再重试"""
|
||||
if not os.path.exists(exe_path):
|
||||
@@ -58,12 +68,12 @@ def build(name, extra_args):
|
||||
cmd = [sys.executable, '-m', 'PyInstaller',
|
||||
'--onefile', '--clean', '--name', name,
|
||||
'--distpath', dist, '--workpath', work, '--specpath', work,
|
||||
] + extra_args + [os.path.join(SRC, 'hdmi_gui.py')]
|
||||
] + extra_args + extra_collect_args() + [os.path.join(SRC, 'hdmi_gui.py')]
|
||||
if os.path.exists(ICO):
|
||||
# exe 文件图标 + 运行时窗口图标资源(从 _MEIPASS 读取)
|
||||
cmd += ['--icon', ICO, '--add-data', f'{ICO};.']
|
||||
else:
|
||||
print('警告: 未找到 logos/logo_ph_orange.ico(先运行 logos/make_ico.py), 本次打包不带图标')
|
||||
print('警告: 未找到 logos/logo_dlpilot.ico(先运行 logos/make_ico.py), 本次打包不带图标')
|
||||
print('\n>>', ' '.join(cmd))
|
||||
subprocess.check_call(cmd)
|
||||
out = os.path.join(dist, name + '.exe')
|
||||
@@ -74,10 +84,10 @@ def main():
|
||||
build_console = '--debug' in sys.argv
|
||||
ensure_pyinstaller()
|
||||
# 常规版: 双击即用, 无黑框(GUI/图案/自定义栏位全部可用, --dump 除外)
|
||||
build('HDMI-Tool', ['--noconsole'])
|
||||
build('DLPilot', ['--noconsole'])
|
||||
if build_console:
|
||||
# 调试版: 带控制台, --dump / 异常栈可见
|
||||
build('HDMI-Tool-console', [])
|
||||
build('DLPilot-console', [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
工具运行配置: 运行目录定位 / pattern_slots.json(自定义栏位 + 串口记忆)
|
||||
================================================================
|
||||
PyInstaller onefile 打包后 __file__ 指向临时解压目录,
|
||||
配置必须落在 exe 旁边, 故 frozen 时改用 sys.executable 定位。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import dlpc_rs485
|
||||
|
||||
# 可选 Pillow: 装了则支持 JPEG/BMP/WebP, 没装则用 tkinter 原生(仅 PNG/GIF)
|
||||
# Image/ImageTk 在此导入一次, patterns.py 复用同一份对象
|
||||
try:
|
||||
from PIL import Image, ImageTk # noqa: F401
|
||||
HAS_PIL = True
|
||||
except ImportError:
|
||||
Image = ImageTk = None
|
||||
HAS_PIL = False
|
||||
|
||||
# 测试卡区域常量(栏位配置与投屏共用)
|
||||
PAT_W, PAT_H = 640, 360
|
||||
AREA_OPTIONS = ('640x360', '800x600', 'full')
|
||||
DEFAULT_AREA = AREA_OPTIONS[0]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 自定义图案栏位: 配置存于脚本目录 pattern_slots.json,
|
||||
# 图片文件同样保存在脚本目录下
|
||||
# 注: PyInstaller onefile 打包后 __file__ 指向临时解压目录,
|
||||
# 配置必须落在 exe 旁边, 故 frozen 时改用 sys.executable 定位
|
||||
# ============================================================
|
||||
if getattr(sys, 'frozen', False):
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(sys.executable))
|
||||
else:
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
SLOT_COUNT = 10
|
||||
SLOT_CONFIG = os.path.join(BASE_DIR, 'pattern_slots.json')
|
||||
IMG_EXTS = ['.png', '.gif'] + (['.jpg', '.jpeg', '.bmp', '.webp'] if HAS_PIL else [])
|
||||
|
||||
# 光机控制串口记忆(与栏位配置同存 pattern_slots.json 的 "serial" 键)
|
||||
SERIAL_CFG = {'port': '', 'baud': dlpc_rs485.DEFAULT_BAUD,
|
||||
'bytesize': 8, 'parity': 'N', 'stopbits': 1}
|
||||
|
||||
|
||||
def load_slots():
|
||||
"""读 pattern_slots.json, 返回 (slots, area)。
|
||||
|
||||
新格式为 {'area': 'WxH'|'full', 'slots': [...], 'serial': {...}};
|
||||
兼容旧版纯数组格式(无 area)与无 serial 键的中间格式。
|
||||
"""
|
||||
slots = [{'name': f'自定义{i + 1}', 'file': None} for i in range(SLOT_COUNT)]
|
||||
try:
|
||||
with open(SLOT_CONFIG, encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
return slots, DEFAULT_AREA
|
||||
if isinstance(data, dict):
|
||||
area = str(data.get('area') or DEFAULT_AREA)
|
||||
items = data.get('slots') or []
|
||||
ser = data.get('serial')
|
||||
if isinstance(ser, dict):
|
||||
SERIAL_CFG['port'] = str(ser.get('port') or '')
|
||||
try:
|
||||
SERIAL_CFG['baud'] = int(ser.get('baud') or dlpc_rs485.DEFAULT_BAUD)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
SERIAL_CFG['bytesize'] = int(ser.get('bytesize') or 8)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
SERIAL_CFG['parity'] = str(ser.get('parity') or 'N').upper()[:1]
|
||||
try:
|
||||
SERIAL_CFG['stopbits'] = int(ser.get('stopbits') or 1)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
else:
|
||||
area, items = DEFAULT_AREA, data
|
||||
for i, item in enumerate(items[:SLOT_COUNT]):
|
||||
if isinstance(item, dict):
|
||||
slots[i]['name'] = str(item.get('name') or slots[i]['name'])
|
||||
slots[i]['file'] = item.get('file') or None
|
||||
return slots, area
|
||||
|
||||
|
||||
def save_slots(slots, area):
|
||||
with open(SLOT_CONFIG, 'w', encoding='utf-8') as f:
|
||||
json.dump({'area': area, 'slots': slots, 'serial': SERIAL_CFG},
|
||||
f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def copy_image_into_script_dir(src_path):
|
||||
"""把任意路径的图片复制到脚本目录, 重名自动加序号, 返回文件名"""
|
||||
dst_name = os.path.basename(src_path)
|
||||
dst = os.path.join(BASE_DIR, dst_name)
|
||||
if os.path.normcase(os.path.abspath(src_path)) == os.path.normcase(dst):
|
||||
return dst_name
|
||||
stem, ext = os.path.splitext(dst_name)
|
||||
n = 1
|
||||
while os.path.exists(dst):
|
||||
dst_name = f'{stem}_{n}{ext}'
|
||||
dst = os.path.join(BASE_DIR, dst_name)
|
||||
n += 1
|
||||
shutil.copy2(src_path, dst)
|
||||
return dst_name
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Win32 显示器枚举 / 分辨率切换 / 显示拓扑(纯 ctypes, 无第三方依赖)
|
||||
================================================================
|
||||
get_displays() 枚举活动显示器(设备名/显卡/监视器/当前模式/矩形/EDID)
|
||||
enum_modes() 显卡按该屏 EDID 提供的模式列表
|
||||
apply_mode() 临时切换分辨率(不写注册表, 重启还原)
|
||||
ensure_display_extend() 智能拓扑: 仅转接板(EDID 厂商 XLS)接入但未进桌面时强制扩展
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import winreg
|
||||
|
||||
# ============================================================
|
||||
# Win32 显示 API
|
||||
# ============================================================
|
||||
user32 = ctypes.windll.user32
|
||||
|
||||
ENUM_CURRENT_SETTINGS = 0xFFFFFFFF # (DWORD)-1
|
||||
|
||||
|
||||
class DISPLAY_DEVICEW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('cb', wt.DWORD),
|
||||
('DeviceName', ctypes.c_wchar * 32),
|
||||
('DeviceString', ctypes.c_wchar * 128),
|
||||
('StateFlags', wt.DWORD),
|
||||
('DeviceID', ctypes.c_wchar * 128),
|
||||
('DeviceKey', ctypes.c_wchar * 128),
|
||||
]
|
||||
|
||||
|
||||
class DEVMODEW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('dmDeviceName', ctypes.c_wchar * 32),
|
||||
('dmSpecVersion', wt.WORD),
|
||||
('dmDriverVersion', wt.WORD),
|
||||
('dmSize', wt.WORD),
|
||||
('dmDriverExtra', wt.WORD),
|
||||
('dmFields', wt.DWORD),
|
||||
('dmPositionX', wt.LONG),
|
||||
('dmPositionY', wt.LONG),
|
||||
('dmDisplayOrientation', wt.DWORD),
|
||||
('dmDisplayFixedOutput', wt.DWORD),
|
||||
('dmColor', wt.WORD),
|
||||
('dmDuplex', wt.WORD),
|
||||
('dmYResolution', wt.WORD),
|
||||
('dmTTOption', wt.WORD),
|
||||
('dmCollate', wt.WORD),
|
||||
('dmFormName', ctypes.c_wchar * 32),
|
||||
('dmLogPixels', wt.WORD),
|
||||
('dmBitsPerPel', wt.DWORD),
|
||||
('dmPelsWidth', wt.DWORD),
|
||||
('dmPelsHeight', wt.DWORD),
|
||||
('dmDisplayFlags', wt.DWORD),
|
||||
('dmDisplayFrequency', wt.DWORD),
|
||||
('dmICMMethod', wt.DWORD),
|
||||
('dmICMIntent', wt.DWORD),
|
||||
('dmMediaType', wt.DWORD),
|
||||
('dmDitherType', wt.DWORD),
|
||||
('dmReserved1', wt.DWORD),
|
||||
('dmReserved2', wt.DWORD),
|
||||
('dmPanningWidth', wt.DWORD),
|
||||
('dmPanningHeight', wt.DWORD),
|
||||
]
|
||||
|
||||
|
||||
user32.EnumDisplayDevicesW.argtypes = [ctypes.c_wchar_p, wt.DWORD,
|
||||
ctypes.POINTER(DISPLAY_DEVICEW), wt.DWORD]
|
||||
user32.EnumDisplayDevicesW.restype = wt.BOOL
|
||||
user32.EnumDisplaySettingsW.argtypes = [ctypes.c_wchar_p, wt.DWORD,
|
||||
ctypes.POINTER(DEVMODEW)]
|
||||
user32.EnumDisplaySettingsW.restype = wt.BOOL
|
||||
user32.ChangeDisplaySettingsExW.argtypes = [ctypes.c_wchar_p, ctypes.POINTER(DEVMODEW),
|
||||
ctypes.c_void_p, wt.DWORD, ctypes.c_void_p]
|
||||
user32.ChangeDisplaySettingsExW.restype = ctypes.c_long
|
||||
|
||||
DM_BITSPERPEL = 0x00040000
|
||||
DM_PELSWIDTH = 0x00080000
|
||||
DM_PELSHEIGHT = 0x00100000
|
||||
DM_DISPLAYFREQUENCY = 0x00400000
|
||||
|
||||
|
||||
def read_edid_from_registry(device_id):
|
||||
"""monitor DeviceID 'MONITOR\\<pnp>\\<inst>' → 注册表 EDID 原始字节
|
||||
|
||||
注意: 间接显示器/虚拟显示驱动返回的 DeviceID 实例段是 '{类GUID}\\NNNN'
|
||||
格式, 注册表中并不存在该路径; 因此精确匹配失败后按 PNP ID 枚举其下
|
||||
全部实例, 取第一份 EDID(同一物理屏的重复实例 EDID 相同)。
|
||||
"""
|
||||
if not device_id or not device_id.startswith('MONITOR\\'):
|
||||
return None
|
||||
parts = device_id.split('\\')
|
||||
if len(parts) < 3:
|
||||
return None
|
||||
pnp = parts[1]
|
||||
inst = device_id.split('\\', 2)[2]
|
||||
|
||||
def _read(path):
|
||||
try:
|
||||
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path)
|
||||
try:
|
||||
val, _typ = winreg.QueryValueEx(key, 'EDID')
|
||||
if isinstance(val, (bytes, bytearray)):
|
||||
return bytes(val)
|
||||
if isinstance(val, list): # 某些系统返回 int 列表
|
||||
return bytes(val)
|
||||
finally:
|
||||
winreg.CloseKey(key)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
# 1) 精确实例路径(常规真实显示器)
|
||||
raw = _read(rf'SYSTEM\CurrentControlSet\Enum\DISPLAY\{pnp}\{inst}\Device Parameters')
|
||||
if raw:
|
||||
return raw
|
||||
|
||||
# 2) 按 PNP ID 遍历全部实例(间接显示器/实例名不一致)
|
||||
try:
|
||||
pnp_root = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
|
||||
rf'SYSTEM\CurrentControlSet\Enum\DISPLAY\{pnp}')
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
for i in range(winreg.QueryInfoKey(pnp_root)[0]):
|
||||
sub = winreg.EnumKey(pnp_root, i)
|
||||
raw = _read(rf'SYSTEM\CurrentControlSet\Enum\DISPLAY\{pnp}\{sub}\Device Parameters')
|
||||
if raw:
|
||||
return raw
|
||||
finally:
|
||||
winreg.CloseKey(pnp_root)
|
||||
return None
|
||||
|
||||
|
||||
def get_displays():
|
||||
"""枚举活动显示器: 设备名/显卡/监视器名/当前模式/屏幕矩形/EDID 字节"""
|
||||
out = []
|
||||
i = 0
|
||||
while True:
|
||||
dd = DISPLAY_DEVICEW()
|
||||
dd.cb = ctypes.sizeof(dd)
|
||||
if not user32.EnumDisplayDevicesW(None, i, ctypes.byref(dd), 0):
|
||||
break
|
||||
if dd.StateFlags & 0x1: # ATTACHED_TO_DESKTOP
|
||||
# 找该输出口上活动的 monitor 条目
|
||||
mon = None
|
||||
j = 0
|
||||
while True:
|
||||
m = DISPLAY_DEVICEW()
|
||||
m.cb = ctypes.sizeof(m)
|
||||
if not user32.EnumDisplayDevicesW(dd.DeviceName, j, ctypes.byref(m), 0):
|
||||
break
|
||||
if m.StateFlags & 0x1:
|
||||
mon = m
|
||||
break
|
||||
j += 1
|
||||
|
||||
cur = ''
|
||||
rect = None
|
||||
dm = DEVMODEW()
|
||||
dm.dmSize = ctypes.sizeof(dm)
|
||||
if user32.EnumDisplaySettingsW(dd.DeviceName, ENUM_CURRENT_SETTINGS,
|
||||
ctypes.byref(dm)):
|
||||
cur = f'{dm.dmPelsWidth}x{dm.dmPelsHeight}@{dm.dmDisplayFrequency}'
|
||||
rect = (dm.dmPositionX, dm.dmPositionY, dm.dmPelsWidth, dm.dmPelsHeight)
|
||||
|
||||
out.append({
|
||||
'device': dd.DeviceName, # \\.\DISPLAY1
|
||||
'adapter': dd.DeviceString, # 显卡名
|
||||
'mon_name': mon.DeviceString if mon else '',
|
||||
'edid': read_edid_from_registry(mon.DeviceID) if mon else None,
|
||||
'cur_mode': cur,
|
||||
'rect': rect, # (x, y, w, h) 物理像素
|
||||
})
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
def enum_modes(device):
|
||||
"""枚举 device(\\\\.\\DISPLAYn) 可用模式 —— 显卡按该屏 EDID 提供,
|
||||
返回 (['WxH@Hz', ...] 按像素数降序, {'WxH@Hz': DEVMODEW})"""
|
||||
seen = {}
|
||||
i = 0
|
||||
while True:
|
||||
dm = DEVMODEW()
|
||||
dm.dmSize = ctypes.sizeof(dm)
|
||||
if not user32.EnumDisplaySettingsW(device, i, ctypes.byref(dm)):
|
||||
break
|
||||
i += 1
|
||||
if dm.dmBitsPerPel != 32:
|
||||
continue
|
||||
key = f'{dm.dmPelsWidth}x{dm.dmPelsHeight}@{dm.dmDisplayFrequency}'
|
||||
seen.setdefault(key, dm)
|
||||
keys = sorted(seen, key=lambda k: tuple(int(v) for v in k.replace('@', 'x').split('x')),
|
||||
reverse=True)
|
||||
return keys, seen
|
||||
|
||||
|
||||
def apply_mode(device, dm):
|
||||
"""把 device 切到 dm 模式(临时切换, 不写注册表, 重启还原), 返回 (成功?, 返回码)"""
|
||||
dm.dmFields = DM_BITSPERPEL | DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY
|
||||
r = user32.ChangeDisplaySettingsExW(device, ctypes.byref(dm), None, 0, None)
|
||||
return r == 0, r # DISP_CHANGE_SUCCESSFUL == 0
|
||||
|
||||
|
||||
def ensure_display_extend():
|
||||
"""仅当转接板显示器"已接入但未进入桌面"时, 才强制扩展拓扑。
|
||||
|
||||
场景: 板卡热插拔后, 若 Windows 停留在"仅电脑屏幕"拓扑(会记住上次
|
||||
Win+P 的选择), 板卡的 EDID 虽已安装、GPU 也在按首选时序输出, 但桌面
|
||||
并未扩展过去, get_displays() 就枚举不到这块"显示器" —— 此时才需要
|
||||
强制扩展把它拉回桌面。
|
||||
|
||||
用户主动选择的拓扑(家里的"仅第二屏幕"、手动关闭的显示器等)一律不动:
|
||||
枚举"接入但不在桌面"的显示器, 只有其 EDID 厂商是转接板签名 "XLS"
|
||||
(字节 0x61 0x93, 上游 lt8619c_edid.c) 时才扩展; 笔记本内屏等其它
|
||||
厂商视为用户有意关闭, 不干预。
|
||||
|
||||
返回 SetDisplayConfig 的返回码(0=成功, -1=未触发, 其它=异常); 静默容错。
|
||||
"""
|
||||
try:
|
||||
board_stuck = False
|
||||
i = 0
|
||||
while True:
|
||||
dd = DISPLAY_DEVICEW()
|
||||
dd.cb = ctypes.sizeof(dd)
|
||||
if not user32.EnumDisplayDevicesW(None, i, ctypes.byref(dd), 0):
|
||||
break
|
||||
i += 1
|
||||
j = 0
|
||||
while True:
|
||||
m = DISPLAY_DEVICEW()
|
||||
m.cb = ctypes.sizeof(m)
|
||||
if not user32.EnumDisplayDevicesW(dd.DeviceName, j, ctypes.byref(m), 0):
|
||||
break
|
||||
j += 1
|
||||
if m.StateFlags & 0x1: # ATTACHED_TO_DESKTOP: 已在桌面
|
||||
continue
|
||||
raw = read_edid_from_registry(m.DeviceID)
|
||||
if len(raw or ()) >= 10 and raw[8:10] == b'\x61\x93':
|
||||
board_stuck = True # 转接板没进桌面 -> 需要拉回
|
||||
if not board_stuck:
|
||||
return -1
|
||||
SDC_TOPOLOGY_EXTEND = 0x00000004
|
||||
SDC_APPLY = 0x00000080
|
||||
return ctypes.windll.user32.SetDisplayConfig(
|
||||
0, None, 0, None, SDC_TOPOLOGY_EXTEND | SDC_APPLY)
|
||||
except Exception:
|
||||
return -1
|
||||
|
||||
@@ -161,7 +161,8 @@ class DLPC485:
|
||||
def __init__(self, on_event, on_status):
|
||||
self.on_event, self.on_status = on_event, on_status
|
||||
self.q = queue.Queue()
|
||||
self._target = None # 想态 (port, baud); None=断开
|
||||
self._target = None # 想态 (port, baud, bytesize, parity, stopbits)
|
||||
self._open_key = None # 当前已打开串口的参数元组
|
||||
self.ser = None
|
||||
self.parser = FrameParser()
|
||||
self._lock = threading.Lock()
|
||||
@@ -194,12 +195,13 @@ class DLPC485:
|
||||
out.append((dev, label))
|
||||
return out
|
||||
|
||||
def open(self, port, baud=DEFAULT_BAUD):
|
||||
def open(self, port, baud=DEFAULT_BAUD, bytesize=8, parity='N', stopbits=1):
|
||||
if not HAS_SERIAL:
|
||||
self.on_status('未安装 pyserial: py -m pip install pyserial')
|
||||
return
|
||||
with self._lock:
|
||||
self._target = (port, int(baud))
|
||||
self._target = (port, int(baud), int(bytesize),
|
||||
str(parity).upper(), int(stopbits))
|
||||
|
||||
def close(self):
|
||||
with self._lock:
|
||||
@@ -224,23 +226,37 @@ class DLPC485:
|
||||
self.q.put((cmd, frame))
|
||||
|
||||
def _run(self):
|
||||
# 串口参数映射(pyserial 常量): 数据位/校验/停止位
|
||||
bytesizes = {8: serial.EIGHTBITS, 7: serial.SEVENBITS,
|
||||
6: serial.SIXBITS, 5: serial.FIVEBITS}
|
||||
parities = {'N': serial.PARITY_NONE, 'E': serial.PARITY_EVEN,
|
||||
'O': serial.PARITY_ODD}
|
||||
stopbits = {1: serial.STOPBITS_ONE, 2: serial.STOPBITS_TWO}
|
||||
while True:
|
||||
with self._lock:
|
||||
target = self._target
|
||||
cur = (self.ser.port, self.ser.baudrate) if self.ser else None
|
||||
if target != cur: # 端口/波特率变化或断开: 统一在此切换
|
||||
cur = self._open_key
|
||||
if target != cur:
|
||||
if self.ser is not None:
|
||||
try:
|
||||
self.ser.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ser = None
|
||||
self._open_key = None
|
||||
self.on_status('已断开')
|
||||
if target:
|
||||
port, baud, dbsize, parity, sbits = target
|
||||
try:
|
||||
self.ser = serial.Serial(target[0], target[1], timeout=0.15)
|
||||
self.ser = serial.Serial(
|
||||
port, baud, bytesize=bytesizes.get(dbsize, serial.EIGHTBITS),
|
||||
parity=parities.get(parity, serial.PARITY_NONE),
|
||||
stopbits=stopbits.get(sbits, serial.STOPBITS_ONE),
|
||||
timeout=0.15)
|
||||
self.parser = FrameParser()
|
||||
self.on_status(f'已连接 {target[0]} @ {target[1]}')
|
||||
self._open_key = target
|
||||
self.on_status(f'已连接 {port} @ {baud} '
|
||||
f'{dbsize}{parity}{sbits}')
|
||||
except Exception as e:
|
||||
self.ser = None
|
||||
with self._lock:
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
EDID 1.4 / CEA-861 解析(纯标准库)
|
||||
================================================================
|
||||
parse_edid() 原始 EDID 字节 -> 结构化 dict(DTD/已建立/标准/CEA-VIC/数据块)
|
||||
edid_info_lines() EDID 信息卡片用的 (项目, 值) 行
|
||||
edid_field_rows()/edid_field_text() 逐字节字段解析
|
||||
hex_dump() 经典 hex + ASCII 排版
|
||||
"""
|
||||
|
||||
import math
|
||||
import struct
|
||||
|
||||
# ============================================================
|
||||
# EDID 1.4 / CEA-861 解析
|
||||
# ============================================================
|
||||
# CTA-861 VIC 1..64 常用映射(65+ 及扩展 VIC 不展开)
|
||||
VIC_TABLE = [
|
||||
'640x480@60', '720x480@60 4:3', '720x480@60 16:9', '1280x720@60', '1920x1080i@60',
|
||||
'720x480i@60 4:3', '720x480i@60 16:9', '720x240p@60 4:3', '720x240p@60 16:9',
|
||||
'1440x480i@60 4:3', '1440x480i@60 16:9', '1440x240p@60 4:3', '1440x240p@60 16:9',
|
||||
'1440x480p@60 4:3', '1440x480p@60 16:9', '1920x1080p@60', '720x576p@50 4:3', '720x576p@50 16:9',
|
||||
'1280x720p@50', '1920x1080i@50', '720x576i@50 4:3', '720x576i@50 16:9',
|
||||
'720x288p@50 4:3', '720x288p@50 16:9',
|
||||
'1440x576i@50 4:3', '1440x576i@50 16:9', '1440x288p@50 4:3', '1440x288p@50 16:9',
|
||||
'1440x576p@50 4:3', '1440x576p@50 16:9', '1920x1080p@50', '1920x1080p@24',
|
||||
'1920x1080p@25', '1920x1080p@30',
|
||||
'2880x480p@60 4:3', '2880x480p@60 16:9', '2880x576p@50 4:3', '2880x576p@50 16:9',
|
||||
'1920x1080i@50(1250)', '1920x1080i@100', '1280x720p@100',
|
||||
'720x576p@100 4:3', '720x576p@100 16:9', '720x576i@100 4:3', '720x576i@100 16:9',
|
||||
'1920x1080i@120', '1280x720p@120',
|
||||
'720x480p@120 4:3', '720x480p@120 16:9', '720x480i@120 4:3', '720x480i@120 16:9',
|
||||
'720x576p@200 4:3', '720x576p@200 16:9', '720x576i@200 4:3', '720x576i@200 16:9',
|
||||
'720x480p@240 4:3', '720x480p@240 16:9', '720x480i@240 4:3', '720x480i@240 16:9',
|
||||
'1280x720p@24', '1280x720p@25', '1280x720p@30', '1920x1080p@120', '1920x1080p@240',
|
||||
# VIC 65-88 为 3D/特殊格式, 少见不展开
|
||||
*[None] * 24,
|
||||
'2560x1080p@50', '2560x1080p@60', '2560x1080p@100', '2560x1080p@120',
|
||||
'3840x2160p@24', '3840x2160p@25', '3840x2160p@30',
|
||||
'3840x2160p@50', '3840x2160p@60', '4096x2160p@24',
|
||||
]
|
||||
|
||||
# 已建立时序位域表(bytes 35-37, 共 24 位, '-' 为保留位)
|
||||
EST_TABLE = [
|
||||
'720x400@70', '720x400@88', '640x480@60', '640x480@67', '640x480@72', '640x480@75',
|
||||
'800x600@56', '800x600@60',
|
||||
'800x600@72', '800x600@75', '832x624@75', '1024x768@87i', '1024x768@60', '1024x768@70',
|
||||
'1024x768@75', '1280x1024@75',
|
||||
'1152x870@75', '-', '-', '-', '-', '-', '-', '-',
|
||||
]
|
||||
|
||||
STD_ASPECT = {0: (10 / 16, '16:10'), 1: (3 / 4, '4:3'), 2: (4 / 5, '5:4'), 3: (9 / 16, '16:9')}
|
||||
DEPTHS = [0, 6, 8, 10, 12, 14, 16, 0]
|
||||
INTERFACES = ['未定义', 'DVI', 'HDMI-A', 'HDMI-B', 'MDDI', 'DisplayPort', '保留', '保留']
|
||||
SYNC_TYPES = ['模拟合成', '双极模拟合成', '数字合成', '数字分离']
|
||||
|
||||
# CEA-861 数据块 tag(CTA-861 标准): 1=音频 2=视频 3=厂商专用(VSDB)
|
||||
# 4=扬声器分配 5=VESA DTC 6=视频格式 7=扩展 tag 集合
|
||||
EXT_TAG_NAMES = {
|
||||
0x00: '视频能力', 0x01: '厂商扩展', 0x02: 'VESA 显示设备', 0x04: 'HDMI 4K/8K 视频格式',
|
||||
0x05: '色度学', 0x06: 'HDR 静态元数据', 0x07: 'HDR 动态元数据', 0x0C: 'YCbCr4:2:0 能力',
|
||||
0x0E: 'YCbCr4:2:0 视频格式', 0x78: 'HDMI 论坛 SCDB',
|
||||
}
|
||||
|
||||
|
||||
def parse_dtd(raw, o):
|
||||
"""解析 18 字节详细时序描述符, 返回 dict"""
|
||||
t = {}
|
||||
t['clk_khz'] = struct.unpack_from('<H', raw, o)[0] * 10 # 10kHz 单位
|
||||
t['hact'] = raw[o + 2] | ((raw[o + 4] & 0xF0) << 4)
|
||||
t['hblank'] = raw[o + 3] | ((raw[o + 4] & 0x0F) << 8)
|
||||
t['vact'] = raw[o + 5] | ((raw[o + 7] & 0xF0) << 4)
|
||||
t['vblank'] = raw[o + 6] | ((raw[o + 7] & 0x0F) << 8)
|
||||
t['hfront'] = raw[o + 8] | ((raw[o + 11] & 0xC0) << 2)
|
||||
t['hsync'] = raw[o + 9] | ((raw[o + 11] & 0x30) << 4)
|
||||
t['vfront'] = ((raw[o + 10] & 0xF0) >> 4) | ((raw[o + 11] & 0x0C) << 2)
|
||||
t['vsync'] = (raw[o + 10] & 0x0F) | ((raw[o + 11] & 0x03) << 4)
|
||||
t['him_mm'] = raw[o + 12] | ((raw[o + 14] & 0xF0) << 4)
|
||||
t['vim_mm'] = raw[o + 13] | ((raw[o + 14] & 0x0F) << 8)
|
||||
fl = raw[o + 17]
|
||||
t['interlaced'] = bool(fl & 0x80)
|
||||
t['sync_type'] = SYNC_TYPES[(fl >> 2) & 3]
|
||||
t['polarity'] = ('V+' if fl & 0x02 else 'V-') + ' ' + ('H+' if fl & 0x01 else 'H-')
|
||||
t['htotal'] = t['hact'] + t['hblank']
|
||||
t['vtotal'] = t['vact'] + t['vblank']
|
||||
t['refresh'] = round(t['clk_khz'] * 1000.0 / (t['htotal'] * t['vtotal']), 1)
|
||||
return t
|
||||
|
||||
|
||||
def _ascii(raw, o, max_len):
|
||||
out = []
|
||||
for i in range(max_len):
|
||||
c = raw[o + i]
|
||||
if c in (0x0A, 0x00):
|
||||
break
|
||||
if 32 <= c < 127:
|
||||
out.append(chr(c))
|
||||
return ''.join(out)
|
||||
|
||||
|
||||
def parse_edid(raw):
|
||||
"""解析 EDID(128 或 256 字节), 返回汇总 dict"""
|
||||
e = {'raw': raw, 'dtds': [], 'est': [], 'std': [], 'cea_video': [], 'cea_blocks': []}
|
||||
n = len(raw)
|
||||
|
||||
e['cksum_base'] = (sum(raw[0:128]) & 0xFF) == 0
|
||||
|
||||
# 厂商 ID: 3 个 5bit 压缩大写字母
|
||||
b0, b1 = raw[8], raw[9]
|
||||
e['manufacturer'] = ''.join([
|
||||
chr(((b0 & 0x7C) >> 2) + 64),
|
||||
chr((((b0 & 0x03) << 3) | ((b1 & 0xE0) >> 5)) + 64),
|
||||
chr((b1 & 0x1F) + 64)])
|
||||
e['product'] = struct.unpack_from('<H', raw, 10)[0]
|
||||
e['serial_num'] = struct.unpack_from('<I', raw, 12)[0]
|
||||
e['week'] = raw[16]
|
||||
e['year'] = 1990 + raw[17]
|
||||
e['ver'] = f'{raw[18]}.{raw[19]}'
|
||||
|
||||
if raw[20] & 0x80:
|
||||
bpc = DEPTHS[(raw[20] >> 4) & 0x7]
|
||||
e['input'] = '数字 / ' + INTERFACES[raw[20] & 0x7] + \
|
||||
(f' / {bpc}bpc' if bpc else ' / 色深未定义')
|
||||
else:
|
||||
e['input'] = '模拟 (VGA 输入)'
|
||||
|
||||
e['wcm'], e['hcm'] = raw[21], raw[22]
|
||||
e['gamma'] = 0 if raw[23] == 0xFF else (raw[23] + 100) / 100.0
|
||||
|
||||
feats = []
|
||||
if raw[24] & 0x80: feats.append('DPMS 待机')
|
||||
if raw[24] & 0x40: feats.append('DPMS 挂起')
|
||||
if raw[24] & 0x20: feats.append('DPMS 关闭')
|
||||
if raw[24] & 0x04: feats.append('sRGB 特性')
|
||||
if raw[24] & 0x02: feats.append('首选时序=原生')
|
||||
e['features'] = ', '.join(feats)
|
||||
|
||||
# 已建立时序位域
|
||||
for i in range(24):
|
||||
bit = 7 - (i % 8)
|
||||
if ((raw[35 + i // 8] >> bit) & 1) and EST_TABLE[i] != '-':
|
||||
e['est'].append(EST_TABLE[i])
|
||||
|
||||
# 标准时序(8 组 2 字节, 0x0101 = 未用)
|
||||
for i in range(38, 54, 2):
|
||||
a, b = raw[i], raw[i + 1]
|
||||
if (a == 1 and b == 1) or (a == 0 and b == 0):
|
||||
continue
|
||||
h = (a + 31) * 8
|
||||
v = round(h * STD_ASPECT[(a >> 6) & 3][0])
|
||||
e['std'].append(f'{h}x{v}@{b + 60}')
|
||||
|
||||
# 基础块 4 个 18 字节描述符区: 前两字节非 0 即 DTD, 否则是监视器描述符
|
||||
e['name'] = e['serial_text'] = e['range_text'] = ''
|
||||
for d in range(54, 126, 18):
|
||||
if raw[d] == 0 and raw[d + 1] == 0:
|
||||
tag = raw[d + 2]
|
||||
if tag == 0xFC:
|
||||
e['name'] = _ascii(raw, d + 5, 13)
|
||||
elif tag == 0xFF:
|
||||
e['serial_text'] = _ascii(raw, d + 5, 13)
|
||||
elif tag == 0xFD:
|
||||
e['range_text'] = (f"V {raw[d+5]}~{raw[d+6]}Hz, H {raw[d+7]}~{raw[d+8]}kHz, "
|
||||
f"像素时钟上限 {raw[d+9] * 10}MHz")
|
||||
else:
|
||||
e['dtds'].append(parse_dtd(raw, d))
|
||||
|
||||
# CEA-861 扩展块
|
||||
e['has_ext'] = raw[126] > 0 and n >= 256
|
||||
e['has_cea'] = False
|
||||
e['cea_rev'] = 0
|
||||
e['cea_caps'] = ''
|
||||
e['cea_phys'] = ''
|
||||
e['cksum_ext'] = False
|
||||
if e['has_ext']:
|
||||
e['cksum_ext'] = (sum(raw[128:256]) & 0xFF) == 0
|
||||
if raw[128] == 0x02:
|
||||
e['has_cea'] = True
|
||||
e['cea_rev'] = raw[129]
|
||||
caps = []
|
||||
if raw[131] & 0x80: caps.append('欠扫描')
|
||||
if raw[131] & 0x40: caps.append('基本音频')
|
||||
if raw[131] & 0x20: caps.append('YCbCr4:4:4')
|
||||
if raw[131] & 0x10: caps.append('YCbCr4:2:2')
|
||||
e['cea_caps'] = ', '.join(caps)
|
||||
|
||||
dtd_off = raw[130]
|
||||
p = 132
|
||||
while dtd_off > 4 and (p - 128) < dtd_off and p + 1 < 256:
|
||||
tag = (raw[p] >> 5) & 7
|
||||
length = raw[p] & 0x1F
|
||||
if length == 0:
|
||||
break
|
||||
data = raw[p + 1:p + 1 + length]
|
||||
if tag == 2: # 视频数据块: VIC 列表
|
||||
for k in range(length):
|
||||
vic = data[k] & 0x7F
|
||||
native = ' (原生)' if data[k] & 0x80 else ''
|
||||
m = (VIC_TABLE[vic - 1] if 1 <= vic <= len(VIC_TABLE) else None) \
|
||||
or f'VIC#{vic}'
|
||||
e['cea_video'].append(m + native)
|
||||
elif tag == 1: # 音频数据块: N 个 3 字节 SAD
|
||||
e['cea_blocks'].append(f'音频数据块 x{length // 3}')
|
||||
elif tag == 4:
|
||||
e['cea_blocks'].append('扬声器分配块')
|
||||
elif tag == 5:
|
||||
e['cea_blocks'].append('VESA DTC 块')
|
||||
elif tag == 6:
|
||||
e['cea_blocks'].append('视频格式块(VFDB)')
|
||||
elif tag == 3:
|
||||
# 厂商专用块: 前 3 字节 IEEE OUI(小端)
|
||||
oui = bytes(data[0:3])
|
||||
if oui == b'\x03\x0c\x00': # HDMI 1.x VSDB: OUI 后 4 字节物理地址
|
||||
if length >= 7:
|
||||
e['cea_phys'] = '.'.join(str(b) for b in data[3:7])
|
||||
e['cea_blocks'].append('HDMI VSDB')
|
||||
elif oui == b'\xd8\x5d\xc4': # HDMI 2.0 HF-VSDB
|
||||
e['cea_blocks'].append('HDMI 2.0 HF-VSDB')
|
||||
else:
|
||||
e['cea_blocks'].append(f'厂商块 OUI={oui.hex(" ")}')
|
||||
elif tag == 7:
|
||||
ext = data[0] if length >= 1 else -1
|
||||
name = EXT_TAG_NAMES.get(ext, f'0x{ext:02X}')
|
||||
e['cea_blocks'].append(f'扩展块({name})')
|
||||
else:
|
||||
e['cea_blocks'].append(f'保留块 tag{tag}')
|
||||
p += 1 + length
|
||||
|
||||
if dtd_off >= 4: # 扩展块 DTD 区
|
||||
q = 128 + dtd_off
|
||||
while q + 18 <= n:
|
||||
if raw[q] == 0 and raw[q + 1] == 0:
|
||||
break
|
||||
e['dtds'].append(parse_dtd(raw, q))
|
||||
q += 18
|
||||
return e
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 文本化
|
||||
# ============================================================
|
||||
def edid_info_lines(e):
|
||||
"""[(项目, 值), ...] 摘要行"""
|
||||
lines = []
|
||||
inch = 0
|
||||
if e['wcm'] > 0 and e['hcm'] > 0:
|
||||
inch = round(math.hypot(e['wcm'], e['hcm']) / 2.54, 1)
|
||||
lines.append(('厂商 / 产品码', f"{e['manufacturer']} / 0x{e['product']:04X}"))
|
||||
lines.append(('显示器名称', e['name'] or '(未声明)'))
|
||||
if e['serial_text']:
|
||||
lines.append(('序列号(文本)', e['serial_text']))
|
||||
lines.append(('生产周 / 年', f"第 {e['week']} 周 / {e['year']} 年"))
|
||||
lines.append(('EDID 版本', e['ver']))
|
||||
lines.append(('输入类型', e['input']))
|
||||
if inch > 0:
|
||||
lines.append(('屏幕尺寸', f"{e['wcm']}cm x {e['hcm']}cm (约 {inch} 英寸)"))
|
||||
if e['gamma'] > 0:
|
||||
lines.append(('Gamma', e['gamma']))
|
||||
if e['features']:
|
||||
lines.append(('特性', e['features']))
|
||||
if e['range_text']:
|
||||
lines.append(('范围限制', e['range_text']))
|
||||
if e['has_ext']:
|
||||
ck = 'OK' if e['cksum_ext'] else '错误!'
|
||||
lines.append(('扩展块', f"CEA-861 rev.{e['cea_rev']} 校验和 {ck}"))
|
||||
if e['cea_caps']:
|
||||
lines.append(('CEA 能力', e['cea_caps']))
|
||||
if e['cea_phys']:
|
||||
lines.append(('HDMI 物理地址', e['cea_phys']))
|
||||
else:
|
||||
lines.append(('扩展块', '无'))
|
||||
lines.append(('基础块校验和', 'OK (0x00)' if e['cksum_base'] else '错误!'))
|
||||
|
||||
# 每个 DTD 的完整时序参数(硬件调试重点)
|
||||
for k, t in enumerate(e['dtds']):
|
||||
tag = 'DTD1(首选)' if k == 0 else f'DTD{k + 1}'
|
||||
lines.append((f'{tag} 模式', f"{t['hact']}x{t['vact']}@{t['refresh']} {t['clk_khz'] / 1000:.3f} MHz"))
|
||||
lines.append((f'{tag} 总时序', f"{t['htotal']}x{t['vtotal']}"))
|
||||
lines.append((f'{tag} 水平', f"有效 {t['hact']} | 前沿 {t['hfront']} | 同步 {t['hsync']} | "
|
||||
f"后沿 {t['htotal'] - t['hact'] - t['hfront'] - t['hsync']}"))
|
||||
lines.append((f'{tag} 垂直', f"有效 {t['vact']} | 前沿 {t['vfront']} | 同步 {t['vsync']} | "
|
||||
f"后沿 {t['vtotal'] - t['vact'] - t['vfront'] - t['vsync']}"))
|
||||
lines.append((f'{tag} 同步', f"{t['sync_type']} {t['polarity']} "
|
||||
f"{'(隔行)' if t['interlaced'] else '(逐行)'}"))
|
||||
if t['him_mm'] > 0:
|
||||
lines.append((f'{tag} 图像尺寸', f"{t['him_mm']}x{t['vim_mm']} mm"))
|
||||
return lines
|
||||
|
||||
|
||||
def hex_dump(b):
|
||||
out = []
|
||||
for row in range((len(b) + 15) // 16):
|
||||
off = row * 16
|
||||
chunk = b[off:off + 16]
|
||||
# 8+8 分组, 组间双空格: 同步头/厂商段一眼分开
|
||||
hx = ' '.join(f'{v:02X}' for v in chunk[:8]) + ' ' + \
|
||||
' '.join(f'{v:02X}' for v in chunk[8:])
|
||||
asc = ''.join(chr(v) if 32 <= v < 127 else '.' for v in chunk)
|
||||
out.append(f'{off:04X}: {hx:<50} {asc}')
|
||||
return '\n'.join(out)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# EDID 逐字节字段解析(调试视图): 基础块 + CTA-861 扩展块
|
||||
# 每个字段一行: [偏移] 原始字节 | 含义 | 解码值 | 备注
|
||||
# ============================================================
|
||||
B_HEAD = '同步头 00 FF FF FF FF FF FF 00'
|
||||
|
||||
|
||||
def _range_limits(d):
|
||||
"""FD 频率范围描述符(18 字节常规布局) -> V/H 范围与像素时钟上限"""
|
||||
try:
|
||||
return (f"V {d[5]}~{d[6]}Hz, H {d[7]}~{d[8]}kHz, "
|
||||
f"像素时钟上限 {d[9] * 10}MHz")
|
||||
except (IndexError, ValueError):
|
||||
return ''
|
||||
|
||||
|
||||
def _bits(b, on, off=None):
|
||||
return f'{b:02X}: ' + ('; '.join(on) if on else (off or '无置位'))
|
||||
|
||||
|
||||
def edid_field_rows(raw):
|
||||
"""把 EDID(128/256 字节)逐字段解成 [(偏移, 原始字节, 含义, 解码值, 备注), ...]"""
|
||||
rows = []
|
||||
n = len(raw)
|
||||
|
||||
def add(off, ln, meaning, val, note=''):
|
||||
hx = raw[off:off + ln].hex(' ').upper()
|
||||
if ln > 18: # 超长区域(如零填充)截断, 完整数据看 hex dump
|
||||
hx = raw[off:off + 18].hex(' ').upper() + f' …(共 {ln} 字节)'
|
||||
rows.append((f'0x{off:02X}-{off + ln - 1:02X}' if ln > 1 else f'0x{off:02X}',
|
||||
hx, meaning, str(val), note))
|
||||
|
||||
add(0, 8, B_HEAD, 'OK' if raw[0:8] == bytes.fromhex('00FFFFFFFFFFFF00') else '不匹配!',
|
||||
'不许改, 用于同步识别')
|
||||
# 8-9 厂商 ID: 16 位大端, 3 组 5bit 压缩大写字母(块内唯一大端字段)
|
||||
mfr = ''.join([chr(((raw[8] & 0x7C) >> 2) + 64),
|
||||
chr((((raw[8] & 0x03) << 3) | ((raw[9] & 0xE0) >> 5)) + 64),
|
||||
chr((raw[9] & 0x1F) + 64)])
|
||||
add(8, 2, '厂商 ID(大端)', mfr,
|
||||
f"3×5bit 压缩字母, 原始 0x{(raw[8] << 8) | raw[9]:04X}; 正式产品应使用注册的 PNP ID")
|
||||
add(10, 2, '产品码(小端)', f"0x{struct.unpack_from('<H', raw, 10)[0]:04X} ({struct.unpack_from('<H', raw, 10)[0]})",
|
||||
'厂商自定')
|
||||
add(12, 4, '序列号(小端)', f"0x{struct.unpack_from('<I', raw, 12)[0]:08X}",
|
||||
'厂商自定')
|
||||
add(16, 1, '生产周', f"第 {raw[16]} 周", '0 = 不指定')
|
||||
add(17, 1, '生产年', f"{1990 + raw[17]} 年", '字节值 + 1990')
|
||||
add(18, 2, 'EDID 版本号', f"{raw[18]}.{raw[19]}", '常用 1.4 / 1.3(即字节 01 04 / 01 03)')
|
||||
|
||||
# 字节 0x14 输入类型
|
||||
if raw[20] & 0x80:
|
||||
itf, bpc = INTERFACES[raw[20] & 0x7], DEPTHS[(raw[20] >> 4) & 0x7]
|
||||
val = f'数字 / {itf}' + (f' / {bpc}bpc' if bpc else ' / 色深未定义')
|
||||
else:
|
||||
val = '模拟输入'
|
||||
add(20, 1, '输入类型', val,
|
||||
'bit7=1 数字; 低 4 位: 1=HDMI-A 2=HDMI-B 4=DP 5=eDP')
|
||||
|
||||
add(21, 1, '最大画面宽(cm)', raw[21], '0 = 未知(横竖比由 DTD 图像尺寸给出)')
|
||||
add(22, 1, '最大画面高(cm)', raw[22], '0 = 未知')
|
||||
add(23, 1, 'Gamma', f"{(raw[23] + 100) / 100:.2f}" if raw[23] else '(未提供)',
|
||||
'存储值 = gamma×100 − 100(如 0x78 → 2.20); 0xFF = 不提供')
|
||||
add(24, 1, '特性位', _bits(raw[24], [
|
||||
*(['DPMS 待机'] if raw[24] & 0x80 else []),
|
||||
*(['DPMS 挂起'] if raw[24] & 0x40 else []),
|
||||
*(['DPMS 关闭'] if raw[24] & 0x20 else []),
|
||||
*([f"显示色彩 {['RGB 4:4:4', 'RGB+YCrCb 4:4:4', 'RGB+YCrCb 4:2:2', '未定义'][(raw[24] >> 3) & 3]}"]
|
||||
if raw[24] & 0x80 else []),
|
||||
*(['sRGB 默认'] if raw[24] & 0x04 else []),
|
||||
*(['首选时序=原生分辨率'] if raw[24] & 0x02 else []),
|
||||
*(['连续频率/GTF'] if raw[24] & 0x01 else []),
|
||||
]), 'bit2 sRGB / bit1 首选时序有效 / bit0 连续频率')
|
||||
add(25, 10, '色度坐标(CIE xy)', '默认 sRGB 模板' if raw[25:35] == bytes.fromhex(
|
||||
'78EE95A3554D9EA6544C') else '(打包位段)', '红绿蓝白四点 xy 各 10bit 打包, 通常照抄模板')
|
||||
est = []
|
||||
est_bits = 0
|
||||
for i in range(24):
|
||||
bit = 7 - (i % 8)
|
||||
if (raw[35 + i // 8] >> bit) & 1:
|
||||
est_bits |= 1 << (23 - i)
|
||||
if EST_TABLE[i] != '-':
|
||||
est.append(EST_TABLE[i])
|
||||
if est:
|
||||
add(35, 3, 'Established Timing 位图', ', '.join(est),
|
||||
'VESA 老 VGA 时序; 数字屏常写 FF FF 00=全支持')
|
||||
else:
|
||||
add(35, 3, 'Established Timing 位图',
|
||||
'无有效声明' + ('(仅保留位置位)' if est_bits else ''),
|
||||
'VESA 老 VGA 时序; 数字屏常写 FF FF 00=全支持')
|
||||
stds = []
|
||||
for i in range(38, 54, 2):
|
||||
a, b = raw[i], raw[i + 1]
|
||||
if (a == 1 and b == 1) or (a == 0 and b == 0):
|
||||
continue
|
||||
h = (a + 31) * 8
|
||||
ar = STD_ASPECT[(a >> 6) & 3][1]
|
||||
stds.append(f"{h}x{round(h * STD_ASPECT[(a >> 6) & 3][0])}@{b + 60} {ar}")
|
||||
add(38, 16, '标准时序 ×8', '; '.join(stds) if stds else '8 槽全部未用(01 01)',
|
||||
'2 字节/槽: 6bit 宽 + 2bit 比例 + 刷新率−60; 未用槽填 01 01')
|
||||
|
||||
# 0x36-0x7D: 4 × 18 字节描述符
|
||||
for k, d in enumerate(range(54, 126, 18)):
|
||||
grp = raw[d:d + 18]
|
||||
if grp[0] or grp[1]:
|
||||
t = parse_dtd(raw, d)
|
||||
note = (f"{'(隔行)' if t['interlaced'] else '(逐行)'} "
|
||||
f"{t['sync_type']} {'/'.join(t['polarity'].split())}; "
|
||||
f"消隐 H{t['hblank']}/V{t['vblank']}, "
|
||||
f"前/同/后 H{t['hfront']}/{t['hsync']}/{t['htotal'] - t['hact'] - t['hfront'] - t['hsync']} "
|
||||
f"V{t['vfront']}/{t['vsync']}/{t['vtotal'] - t['vact'] - t['vfront'] - t['vsync']}")
|
||||
add(d, 18, f'描述符{k + 1}: DTD(详细时序)',
|
||||
f"{t['hact']}×{t['vact']}@{t['refresh']}Hz "
|
||||
f"总 {t['htotal']}×{t['vtotal']} {t['clk_khz'] / 1000:.3f}MHz"
|
||||
+ (' [首选时序]' if k == 0 else ''),
|
||||
note + ('; 图像尺寸 %dx%d mm' % (t['him_mm'], t['vim_mm']) if t['him_mm'] else ''))
|
||||
else:
|
||||
tag = grp[3]
|
||||
txt = _ascii(raw, d + 5, 13)
|
||||
if tag == 0xFF:
|
||||
add(d, 18, f'描述符{k + 1}: 序列号串(0xFF)', repr(txt), 'ASCII, 上限 13 字节')
|
||||
elif tag == 0xFC:
|
||||
add(d, 18, f'描述符{k + 1}: 显示器名(0xFC)', repr(txt), 'ASCII, 上限 13 字节')
|
||||
elif tag == 0xFD:
|
||||
add(d, 18, f'描述符{k + 1}: 范围限制(0xFD)', _range_limits(grp), '频率范围描述符')
|
||||
elif tag == 0xFE:
|
||||
add(d, 18, f'描述符{k + 1}: 无界文本(0xFE)', repr(txt), '')
|
||||
elif tag == 0x10:
|
||||
add(d, 18, f'描述符{k + 1}: 数据串(0x10)', grp[5:18].hex(' ').upper(), '二进制用途')
|
||||
elif tag == 0 and not any(grp):
|
||||
add(d, 18, f'描述符{k + 1}: 空', '(全 00)', '')
|
||||
else:
|
||||
add(d, 18, f'描述符{k + 1}: 未知类型 0x{tag:02X}', grp[5:18].hex(' ').upper(), '')
|
||||
|
||||
add(126, 1, '扩展块数量', raw[126], '必须与实际扩展块数一致')
|
||||
cksum = (256 - sum(raw[0:127])) & 0xFF
|
||||
add(127, 1, '基础块校验和', f"0x{raw[127]:02X} → " + ('正确' if (sum(raw[0:128]) & 0xFF) == 0 else '错误!'),
|
||||
f"整块 128 字节和 ≡ 0 (mod 256); 期望 0x{cksum:02X} = 256 − 前 127 字节和")
|
||||
|
||||
# ---- CTA-861 扩展块 ----
|
||||
n_ext = raw[126] if n > 126 else 0
|
||||
for bi in range(n_ext):
|
||||
base = 128 + bi * 128
|
||||
if base + 128 > n:
|
||||
add(base, 0, f'扩展块{bi + 1}', '(数据不完整)', '')
|
||||
break
|
||||
blk = raw[base:base + 128]
|
||||
add(base, 1, f'扩展块{bi + 1} @0x{base:02X} Tag',
|
||||
'CTA-861' if blk[0] == 0x02 else f'0x{blk[0]:02X}(非 CTA)',
|
||||
'固定 0x02 = CTA 扩展; HDMI 必须有')
|
||||
if blk[0] != 0x02:
|
||||
add(base + 1, 127, '扩展块数据', blk[1:128].hex(' ').upper(), '未知扩展类型, 跳过')
|
||||
continue
|
||||
add(base + 1, 1, '版本', f"rev {blk[1]}", '常用 03')
|
||||
add(base + 2, 1, 'DTD 起始偏移', f"0x{blk[2]:02X} (= 块内 {blk[2]} 字节处)", '数据块链结束、DTD 区开始的偏移')
|
||||
add(base + 3, 1, '标志位', _bits(blk[3], [
|
||||
*(['bit7 支持 YCbCr 4:2:0'] if blk[3] & 0x80 else []),
|
||||
*(['bit6 支持基础音频'] if blk[3] & 0x40 else []),
|
||||
*(['bit5 支持 YCbCr 4:2:2'] if blk[3] & 0x20 else []),
|
||||
*(['bit4 支持 YCbCr 4:4:4'] if blk[3] & 0x10 else []),
|
||||
*(['bit0 支持欠扫描'] if blk[3] & 0x01 else []),
|
||||
]), 'bit6=基础音频, bit5=4:2:2, bit7=4:2:0, bit0=欠扫描')
|
||||
|
||||
# 数据块链: 每块 1 字节头(高 3 位 tag + 低 5 位长度) + payload
|
||||
TAG_NAMES = {1: '音频', 2: '视频 VIC', 3: '厂商专用 VSDB', 4: '扬声器分配',
|
||||
5: 'VESA DTC', 6: '视频格式 VFDB', 7: '扩展 tag'}
|
||||
p, first = 4, True
|
||||
while base + p < base + 128 and p < blk[2] and p + 1 < 128:
|
||||
tag, length = (blk[p] >> 5) & 7, blk[p] & 0x1F
|
||||
payload = blk[p + 1:p + 1 + length]
|
||||
if first and blk[p] == 0:
|
||||
add(base + p, 1, '数据块链', '(无数据块, 直接进 DTD 区)', '4 至 DTD 区前为数据块链')
|
||||
break
|
||||
first = False
|
||||
if tag == 2:
|
||||
vics = []
|
||||
for v in payload:
|
||||
vi = v & 0x7F
|
||||
name = (VIC_TABLE[vi - 1] if 1 <= vi <= len(VIC_TABLE) else None) or f'VIC#{vi}'
|
||||
vics.append(name + ('(原生)' if v & 0x80 else ''))
|
||||
add(base + p, 1 + length, '数据块: 视频', ' '.join(vics) or '(空)', f"tag 2, {length} 字节")
|
||||
elif tag == 1:
|
||||
fmts = []
|
||||
codes = ['保留', 'LPCM', 'AAC', 'AC-3', 'MPEG1', 'MP3', 'MPEG2', 'AAC LC',
|
||||
'DTS', 'ATRAC', 'DSD', 'E-AC-3', 'DTS-HD', 'MLP', 'DST', 'WMAPro']
|
||||
for si in range(length // 3):
|
||||
sad = payload[si * 3:(si + 1) * 3]
|
||||
ch = (sad[0] & 7) + 1
|
||||
rates = '/'.join(str(r) for r, b in ((32, 1), (44, 2), (48, 4)) if sad[2] & b) or '?'
|
||||
mults = ''.join(f'×{m}' for m, b in ((2, 8), (3, 16), (4, 32)) if sad[2] & b)
|
||||
if (sad[0] >> 3) & 0xF == 1: # LPCM: 位宽在字节1低3位(位图)
|
||||
bits = '/'.join(str(v) for v, b in ((16, 1), (20, 2), (24, 4)) if sad[1] & b) or '?'
|
||||
fmts.append(f'LPCM {ch}ch {bits}bit {rates}kHz{mults}')
|
||||
else: # 压缩格式: 字节1高4位=最高采样率档
|
||||
fmts.append(f"{codes[(sad[0] >> 3) & 0xF]} {ch}ch {rates}kHz{mults}")
|
||||
add(base + p, 1 + length, '数据块: 音频', '; '.join(fmts), f"tag 1, {length // 3} × 3 字节 SAD")
|
||||
elif tag == 3:
|
||||
oui = bytes(payload[0:3])
|
||||
names = {b'\x03\x0c\x00': 'HDMI 1.x VSDB', b'\xd8\x5d\xc4': 'HDMI 2.0 HF-VSDB'}
|
||||
val = names.get(oui, f'OUI={oui.hex(" ").upper()}')
|
||||
if oui == b'\x03\x0c\x00' and length >= 7:
|
||||
val += f", 物理地址 {'.'.join(str(b) for b in payload[3:7])}"
|
||||
add(base + p, 1 + length, '数据块: 厂商专用', val,
|
||||
f"tag 3, 头 3 字节 IEEE OUI(小端){'; HDMI 2.0 需 HF-VSDB' if oui == b'\x03\x0c\x00' else ''}")
|
||||
else:
|
||||
name = TAG_NAMES.get(tag, f'tag{tag}')
|
||||
add(base + p, 1 + length, f'数据块: {name}',
|
||||
payload.hex(' ').upper() if length else '(空)', f"{length} 字节 payload")
|
||||
if length == 0:
|
||||
break
|
||||
p += 1 + length
|
||||
|
||||
# DTD 区
|
||||
if blk[2] >= 4:
|
||||
q, cnt = blk[2], 0
|
||||
while q + 18 <= 128 and any(blk[q:q + 2]):
|
||||
t = parse_dtd(blk, q)
|
||||
add(base + q, 18, f'扩展块 DTD{cnt + 1}',
|
||||
f"{t['hact']}×{t['vact']}@{t['refresh']}Hz 总 {t['htotal']}×{t['vtotal']} "
|
||||
f"{t['clk_khz'] / 1000:.3f}MHz",
|
||||
"前/同/后 H%d/%d/%d V%d/%d/%d %s" % (
|
||||
t['hfront'], t['hsync'], t['htotal'] - t['hact'] - t['hfront'] - t['hsync'],
|
||||
t['vfront'], t['vsync'], t['vtotal'] - t['vact'] - t['vfront'] - t['vsync'],
|
||||
'(隔行)' if t['interlaced'] else '(逐行)'))
|
||||
cnt += 1
|
||||
q += 18
|
||||
if not cnt:
|
||||
add(base + blk[2], 0, '扩展块 DTD 区', '(空)', '未用空间填 0')
|
||||
rem = 128 - max(blk[2], 4) - 18 * cnt if blk[2] >= 4 else 0
|
||||
if blk[2] >= 4 and rem > 0 and not any(blk[128 - rem:128 - 1]):
|
||||
add(base + 128 - rem, rem - 1, '未用空间', '0 填充', '')
|
||||
add(base + 127, 1, f'扩展块{bi + 1} 校验和',
|
||||
f"0x{blk[127]:02X} → " + ('正确' if (sum(blk) & 0xFF) == 0 else '错误!'),
|
||||
f"期望 0x{(256 - sum(blk[0:127])) & 0xFF:02X} (同基础块规则)")
|
||||
return rows
|
||||
|
||||
|
||||
def edid_field_text(raw):
|
||||
"""逐字段解析 -> 多行文本(命令行 / 文本框共用)"""
|
||||
w = (7, 26, 20)
|
||||
out = ['%-*s %-*s %-*s %s' % (w[0], '偏移', w[1], '含义', w[2], '解码值', '备注 / 原始字节')]
|
||||
for off, hx, meaning, val, note in edid_field_rows(raw):
|
||||
line = '%-*s %-*s %-*s %s' % (w[0], off, w[1], meaning, w[2], val, note)
|
||||
out.append(line.rstrip())
|
||||
if hx:
|
||||
out.append('%-*s %-*s %s %s' % (w[0], '', w[1], '', '原始', hx))
|
||||
return '\n'.join(out)
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" fill="#000000"/>
|
||||
<rect x="38" y="64" width="436" height="186" rx="34" fill="#FF9900"/>
|
||||
<text x="256" y="210" font-family="Arial, 'Microsoft YaHei', sans-serif" font-size="146" font-weight="900" fill="#000000" text-anchor="middle">HDMI</text>
|
||||
<text x="256" y="421" font-family="Arial, 'Microsoft YaHei', sans-serif" font-size="146" font-weight="900" fill="#FFFFFF" text-anchor="middle">Tool</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 506 B |
@@ -1,13 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
把 logo_ph_orange.svg 同款设计栅格化为多尺寸 .ico(供 exe 图标)
|
||||
DLPilot 图标栅格化: 黑底 512, 橙色圆角矩形, 第一行 DLP(框内黑字), 第二行 Pilot(白字)
|
||||
================================================================
|
||||
设计参数与 SVG 严格一致: 黑底 512, 橙色圆角矩形 x38 y64 w436 h186 rx34,
|
||||
HDMI 146px Arial Bold 黑字(框内居中), Tool 146px 白字(基线 421)。
|
||||
设计参数: 黑底 512, 橙色圆角矩形 x38 y64 w436 h186 rx34,
|
||||
DLP 146px Arial Bold 黑字(框内居中), Pilot 146px Arial Bold 白字(基线 421)。
|
||||
|
||||
输出: logos/logo_ph_orange.png(512, 预览/复用)
|
||||
logos/logo_ph_orange.ico(16~256 七档, exe 图标用)
|
||||
输出: logos/logo_dlpilot.png(512, 预览/复用)
|
||||
logos/logo_dlpilot.ico(16~256 七档, exe 图标用)
|
||||
|
||||
用法: python make_ico.py
|
||||
"""
|
||||
@@ -30,17 +30,17 @@ def render(size=512):
|
||||
raise SystemExit(f'未找到字体 {FONT}, 无法栅格化')
|
||||
f = ImageFont.truetype(FONT, max(8, round(146 * s)))
|
||||
cx = size / 2
|
||||
# HDMI: 居中于橙色框
|
||||
d.text((cx, 64 * s + 186 * s / 2), 'HDMI', font=f, fill=(0, 0, 0), anchor='mm')
|
||||
# Tool: 与 SVG 基线 421 对齐(无下伸字母, 中心 = 421 - cap/2 ~= 368)
|
||||
d.text((cx, 368 * s), 'Tool', font=f, fill=(255, 255, 255), anchor='mm')
|
||||
# DLP: 居中于橙色框
|
||||
d.text((cx, 64 * s + 186 * s / 2), 'DLP', font=f, fill=(0, 0, 0), anchor='mm')
|
||||
# Pilot: 与原设计基线 421 对齐(无下伸字母, 中心 = 421 - cap/2 ~= 368)
|
||||
d.text((cx, 368 * s), 'Pilot', font=f, fill=(255, 255, 255), anchor='mm')
|
||||
return img
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
img = render(512)
|
||||
png = os.path.join(BASE, 'logo_ph_orange.png')
|
||||
ico = os.path.join(BASE, 'logo_ph_orange.ico')
|
||||
png = os.path.join(BASE, 'logo_dlpilot.png')
|
||||
ico = os.path.join(BASE, 'logo_dlpilot.ico')
|
||||
img.save(png)
|
||||
img.save(ico, sizes=[(16, 16), (24, 24), (32, 32), (48, 48),
|
||||
(64, 64), (128, 128), (256, 256)])
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
组装 HDMI-Tool 发布包: exe + 附属资产 → 一个文件夹 + 一个 zip(解压即用)
|
||||
组装 DLPilot 发布包: exe + 附属资产 → 一个文件夹 + 一个 zip(解压即用)
|
||||
================================================================
|
||||
先把 assets/ 的图案复制进 release/(与 exe 平铺), 使 release/ 本身就是
|
||||
可直接使用的完整文件夹; 再原样压成 zip —— 两者内容始终一致。
|
||||
|
||||
zip 解压后根目录为 HDMI-Tool/, 与 exe 运行目录结构一致:
|
||||
HDMI-Tool.exe 主程序(先运行 build_exe.py 打包)
|
||||
zip 解压后根目录为 DLPilot/, 与 exe 运行目录结构一致:
|
||||
DLPilot.exe 主程序(先运行 build_exe.py 打包)
|
||||
pattern_slots.json 预置自定义栏位(指向前 3 张 bw 图案)
|
||||
bw_*.png 640x360 黑白测试图 ×5(自定义栏位用)
|
||||
solid_*.png 800x600 纯色测试图 ×10(RGB 位交换测试用)
|
||||
README.md 使用说明
|
||||
|
||||
用法:
|
||||
python package_release.py 版本号自动取最近 git tag(HDMI-Tool-v*)
|
||||
python package_release.py 版本号自动取最近 git tag(DLPilot-v*)
|
||||
python package_release.py 0.1.0 手动指定版本号
|
||||
产物: release/HDMI-Tool-v<版本>-win64.zip(zip 不入库, 上传到 Gitea Release)
|
||||
产物: release/DLPilot-v<版本>-win64.zip(zip 不入库, 上传到 Gitea Release)
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -35,23 +35,23 @@ PATTERN_GLOBS = ('bw_*.png', 'solid_*.png')
|
||||
|
||||
|
||||
def detect_version():
|
||||
"""从最近的 HDMI-Tool-v* tag 推断版本号, 无 tag 则报错提示手动指定"""
|
||||
"""从最近的 DLPilot-v* tag 推断版本号, 无 tag 则报错提示手动指定"""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
['git', 'describe', '--tags', '--abbrev=0', '--match', 'HDMI-Tool-v*'],
|
||||
['git', 'describe', '--tags', '--abbrev=0', '--match', 'DLPilot-v*'],
|
||||
cwd=BASE, stderr=subprocess.DEVNULL)
|
||||
return out.decode().strip().removeprefix('HDMI-Tool-v')
|
||||
return out.decode().strip().removeprefix('DLPilot-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, 'HDMI-Tool.exe')
|
||||
exe = os.path.join(RELEASE, 'DLPilot.exe')
|
||||
slots = os.path.join(RELEASE, 'pattern_slots.json')
|
||||
readme = os.path.join(BASE, 'README.md')
|
||||
if not os.path.exists(exe):
|
||||
sys.exit('缺少 release/HDMI-Tool.exe, 先运行: python build_exe.py')
|
||||
sys.exit('缺少 release/DLPilot.exe, 先运行: python build_exe.py')
|
||||
for must in (slots, readme):
|
||||
if not os.path.exists(must):
|
||||
sys.exit(f'缺少 {must}')
|
||||
@@ -70,11 +70,11 @@ def main():
|
||||
shutil.copy2(os.path.join(ASSETS, p), os.path.join(RELEASE, p))
|
||||
shutil.copy2(readme, os.path.join(RELEASE, 'README.md'))
|
||||
|
||||
zip_name = f'HDMI-Tool-v{version}-win64.zip'
|
||||
zip_name = f'DLPilot-v{version}-win64.zip'
|
||||
zip_path = os.path.join(RELEASE, zip_name)
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z:
|
||||
for p in ['HDMI-Tool.exe', 'pattern_slots.json', 'README.md'] + patterns:
|
||||
z.write(os.path.join(RELEASE, p), f'HDMI-Tool/{p}')
|
||||
for p in ['DLPilot.exe', 'pattern_slots.json', 'README.md'] + patterns:
|
||||
z.write(os.path.join(RELEASE, p), f'DLPilot/{p}')
|
||||
print(f'发布文件夹: {RELEASE} (exe+配置+说明+{len(patterns)} 张图案, 平铺即用)')
|
||||
print(f'发布包: {zip_path} ({os.path.getsize(zip_path) / 1024 / 1024:.1f} MB)')
|
||||
print('下一步上传: python publish_gitea.py', zip_name)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
测试图案全屏投屏窗口(tkinter): 纯色/棋盘格/网格线 + 自定义图片 1:1 居中
|
||||
================================================================
|
||||
投屏窗口为无边框黑底 Toplevel, ESC 或点击退出; 测试卡区域可选
|
||||
640x360 / 800x600 / full 铺满(常量与图片加载配置在 config)。
|
||||
"""
|
||||
|
||||
from config import DEFAULT_AREA, HAS_PIL, Image, ImageTk, PAT_W, PAT_H
|
||||
|
||||
|
||||
PATTERN_NAMES = {'Red': '纯红', 'Green': '纯绿', 'Blue': '纯蓝',
|
||||
'Checker': '黑白棋盘格', 'Grid': '网格线'}
|
||||
# 纯色测试值(位交换测试用, 与 assets/solid_*.png 对应):
|
||||
# 每通道 55/AA 互为镜像对(检测对), FF 全高基准
|
||||
SOLID_ROWS = (
|
||||
('R', (('55', '#550000'), ('AA', '#AA0000'), ('FF', '#FF0000'))),
|
||||
('G', (('55', '#005500'), ('AA', '#00AA00'), ('FF', '#00FF00'))),
|
||||
('B', (('55', '#000055'), ('AA', '#0000AA'), ('FF', '#0000FF'))),
|
||||
)
|
||||
|
||||
|
||||
def load_image_raw(path):
|
||||
"""按原始像素加载图片(不缩放不裁剪; PIL 仅用于支持 JPG/BMP/WebP 等格式)"""
|
||||
import tkinter as tk
|
||||
if HAS_PIL:
|
||||
return ImageTk.PhotoImage(Image.open(path).convert('RGB'))
|
||||
return tk.PhotoImage(file=path)
|
||||
|
||||
|
||||
def _area_box(spec, w, h):
|
||||
"""区域选项 -> (x0, y0, aw, ah): 'full' 铺满整个屏幕, 'WxH' 黑底居中"""
|
||||
if spec == 'full':
|
||||
return 0, 0, w, h
|
||||
try:
|
||||
aw, ah = (int(v) for v in spec.lower().split('x', 1))
|
||||
except Exception:
|
||||
aw, ah = PAT_W, PAT_H
|
||||
return (w - aw) // 2, (h - ah) // 2, aw, ah
|
||||
|
||||
|
||||
def _open_fullscreen(parent, rect, auto_close=0):
|
||||
"""创建覆盖 rect(x,y,w,h) 的无边框全屏黑底窗口, 返回 (top, cv)
|
||||
|
||||
只支持 ESC 退出: overrideredirect 窗口必须 focus_force 强取键盘焦点,
|
||||
否则收不到按键(Windows 上该组合有效)。
|
||||
"""
|
||||
import tkinter as tk
|
||||
rx, ry, rw, rh = rect
|
||||
top = tk.Toplevel(parent)
|
||||
top.overrideredirect(True)
|
||||
top.geometry(f'{rw}x{rh}+{rx}+{ry}')
|
||||
top.attributes('-topmost', True)
|
||||
top.configure(bg='black')
|
||||
|
||||
cv = tk.Canvas(top, bg='black', highlightthickness=0, bd=0)
|
||||
cv.pack(fill='both', expand=True)
|
||||
|
||||
def close(_evt=None):
|
||||
top.destroy()
|
||||
|
||||
top.bind('<Escape>', close)
|
||||
if auto_close > 0:
|
||||
top.after(auto_close * 1000, close)
|
||||
top.focus_force()
|
||||
return top, cv
|
||||
|
||||
|
||||
def show_pattern(parent, rect, kind, auto_close=0, area=DEFAULT_AREA):
|
||||
"""在 rect 指定的屏幕上全屏出居中测试卡(无任何 OSD 修饰), 区域由 area 指定"""
|
||||
top, cv = _open_fullscreen(parent, rect, auto_close)
|
||||
|
||||
def draw(_evt=None):
|
||||
cv.delete('all')
|
||||
w, h = max(cv.winfo_width(), rect[2]), max(cv.winfo_height(), rect[3])
|
||||
x0, y0, aw, ah = _area_box(area, w, h)
|
||||
|
||||
if kind.startswith('#'): # 纯色测试值: 直接填充指定颜色
|
||||
cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill=kind, outline='')
|
||||
elif kind == 'Red':
|
||||
cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill='#FF0000', outline='')
|
||||
elif kind == 'Green':
|
||||
cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill='#00FF00', outline='')
|
||||
elif kind == 'Blue':
|
||||
cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill='#0000FF', outline='')
|
||||
elif kind == 'Checker':
|
||||
# 40px 黑白格, 格数随区域尺寸自适应(640x360 = 16x9 格)
|
||||
for gy in range((ah + 39) // 40):
|
||||
for gx in range((aw + 39) // 40):
|
||||
if (gx + gy) % 2 == 0:
|
||||
cv.create_rectangle(x0 + gx * 40, y0 + gy * 40,
|
||||
x0 + (gx + 1) * 40, y0 + (gy + 1) * 40,
|
||||
fill='#FFFFFF', outline='')
|
||||
elif kind == 'Grid':
|
||||
# 20px 白色网格线, 黑底, 无其它修饰
|
||||
for k in range(aw // 20 + 1):
|
||||
cv.create_line(x0 + k * 20, y0, x0 + k * 20, y0 + ah, fill='#FFFFFF')
|
||||
for k in range(ah // 20 + 1):
|
||||
cv.create_line(x0, y0 + k * 20, x0 + aw, y0 + k * 20, fill='#FFFFFF')
|
||||
|
||||
top.bind('<Configure>', draw)
|
||||
top.after(50, draw)
|
||||
top.wait_window(top)
|
||||
|
||||
|
||||
def show_pattern_image(parent, rect, path, auto_close=0, area=DEFAULT_AREA):
|
||||
"""全屏黑底, 图片 1:1 居中于 area 指定的测试卡区域:
|
||||
小于区域 -> 真实像素居中, 四周黑边;
|
||||
大于区域 -> 等效从中心截取(区域外用黑矩形遮挡)"""
|
||||
top, cv = _open_fullscreen(parent, rect, auto_close)
|
||||
|
||||
def draw(_evt=None):
|
||||
cv.delete('all')
|
||||
w, h = max(cv.winfo_width(), rect[2]), max(cv.winfo_height(), rect[3])
|
||||
x0, y0, aw, ah = _area_box(area, w, h)
|
||||
photo = load_image_raw(path)
|
||||
top._photo = photo # 挂在窗口上防止被垃圾回收
|
||||
iw, ih = photo.width(), photo.height()
|
||||
cv.create_image(x0 + (aw - iw) // 2, y0 + (ah - ih) // 2,
|
||||
image=photo, anchor='nw')
|
||||
# 四块黑矩形盖住测试卡区域之外 -> 大图等效中心裁剪
|
||||
cv.create_rectangle(-2, -2, w + 2, y0, fill='black', outline='')
|
||||
cv.create_rectangle(-2, y0 + ah, w + 2, h + 2, fill='black', outline='')
|
||||
cv.create_rectangle(-2, y0, x0, y0 + ah, fill='black', outline='')
|
||||
cv.create_rectangle(x0 + aw, y0, w + 2, y0 + ah, fill='black', outline='')
|
||||
|
||||
top.bind('<Configure>', draw)
|
||||
top.after(50, draw)
|
||||
top.wait_window(top)
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
创建/更新 Gitea Release 并上传发布 zip(纯标准库, 无需安装依赖)
|
||||
================================================================
|
||||
用法:
|
||||
python publish_gitea.py HDMI-Tool-v0.1.0-win64.zip
|
||||
python publish_gitea.py DLPilot-v0.1.0-win64.zip
|
||||
python publish_gitea.py <zip> --notes-file release/notes-v0.1.0.md
|
||||
python publish_gitea.py <zip> --tag HDMI-Tool-v0.1.0 --replace
|
||||
python publish_gitea.py <zip> --tag DLPilot-v0.1.0 --replace
|
||||
|
||||
行为:
|
||||
- tag 与版本默认取 zip 文件名(HDMI-Tool-vX.Y.Z-win64.zip → HDMI-Tool-vX.Y.Z)
|
||||
- tag 与版本默认取 zip 文件名(DLPilot-vX.Y.Z-win64.zip → DLPilot-vX.Y.Z)
|
||||
- release 不存在则创建(tag 不存在时 Gitea 在默认分支头自动建 tag);
|
||||
已存在则复用, 仅上传/更新资产
|
||||
- release 上已有同名资产时: 默认跳过, --replace 删旧再传
|
||||
@@ -27,16 +27,16 @@ import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
DEFAULT_NOTES = """## HDMI-Tool {version}
|
||||
DEFAULT_NOTES = """## DLPilot {version}
|
||||
|
||||
HDMI 源端调试工具(LT8619C → DLPC3421 转接板配套): EDID 查看/逐字节解析、
|
||||
分辨率切换、纯色与测试图案全屏投屏。
|
||||
|
||||
### 安装
|
||||
下载 `HDMI-Tool-v{version}-win64.zip`, 解压后双击 `HDMI-Tool.exe`(Windows 10/11 x64, 免安装)。
|
||||
下载 `DLPilot-v{version}-win64.zip`, 解压后双击 `DLPilot.exe`(Windows 10/11 x64, 免安装)。
|
||||
|
||||
### 包内容
|
||||
- `HDMI-Tool.exe` — 主程序
|
||||
- `DLPilot.exe` — 主程序
|
||||
- `pattern_slots.json` — 预置自定义栏位(指向 bw_*.png)
|
||||
- `bw_*.png` ×5 / `solid_*.png` ×10 — 测试图案
|
||||
- `README.md` — 完整使用说明
|
||||
@@ -119,7 +119,7 @@ def main():
|
||||
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('HDMI-Tool-v')
|
||||
version = tag.removeprefix('DLPilot-v')
|
||||
notes = open(args.notes_file, encoding='utf-8').read() if args.notes_file \
|
||||
else DEFAULT_NOTES.format(version=version)
|
||||
|
||||
@@ -133,7 +133,7 @@ def main():
|
||||
else:
|
||||
status, rel = api.request('POST', '/releases', json.dumps({
|
||||
'tag_name': tag,
|
||||
'name': f'HDMI-Tool v{version}',
|
||||
'name': f'DLPilot v{version}',
|
||||
'body': notes,
|
||||
}).encode(), {'Content-Type': 'application/json'})
|
||||
if status != 201:
|
||||
|
||||
@@ -7,15 +7,16 @@ LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套上位机工
|
||||
|
||||
| 工具 | 用途 | 获取 |
|
||||
|---|---|---|
|
||||
| [HDMI-Tool](HDMI-Tool/) | HDMI 源端(EDID 查看/逐字节解析、分辨率切换、纯色与测试图案全屏投屏)+ 光机控制(RS485→GD32 网关→DLPC3421:开关光机/设光强/查温度电流) | 到 [Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases) 下 `HDMI-Tool-vX.Y.Z-win64.zip`,解压即用;详见 [HDMI-Tool/README.md](HDMI-Tool/README.md) |
|
||||
| [DLPilot](DLPilot/) | HDMI 源端(EDID 查看/逐字节解析、分辨率切换、纯色与测试图案全屏投屏)+ 光机控制(RS485→GD32 网关→DLPC3421:开关光机/设光强/查温度电流) | 到 [Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases) 下 `DLPilot-vX.Y.Z-win64.zip`,解压即用;详见 [DLPilot/README.md](DLPilot/README.md) |
|
||||
| [I2C-Inject](I2C-Inject/) | LT8619C 寄存器在线读写:RP2040 注入器在 demo 板 I2C 总线上择机注入(避让主控轮询,写后自动读回校验) | 需配合 RP2040 固件 `rp2040_inject.py`;详见 [I2C-Inject/README.md](I2C-Inject/README.md) |
|
||||
|
||||
## 环境要求
|
||||
|
||||
- 发布版 zip 开箱即用(Windows 10/11 x64,无需安装 Python)
|
||||
- 源码运行:Python ≥3.10;HDMI-Tool 为纯标准库(自定义图片 JPG/BMP 需可选 Pillow);
|
||||
- 源码运行:Python ≥3.10;DLPilot 命令行/EDID 纯标准库,GUI 需
|
||||
`py -m pip install customtkinter`(自定义图片 JPG/BMP 可选 Pillow、光机控制可选 pyserial);
|
||||
I2C-Inject 需 `py -m pip install pyserial`
|
||||
- 重新打包:`HDMI-Tool/src/build_exe.py`(自动安装 PyInstaller);I2C-Inject 目录内 `python build_exe.py`
|
||||
- 重新打包:`DLPilot/src/build_exe.py`(自动安装 PyInstaller);I2C-Inject 目录内 `python build_exe.py`
|
||||
|
||||
## 目录结构
|
||||
|
||||
@@ -24,7 +25,7 @@ LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套上位机工
|
||||
|
||||
```
|
||||
LT8619C_Debug_Tools/
|
||||
├── HDMI-Tool/
|
||||
├── DLPilot/
|
||||
│ ├── src/ # 源码 + 打包/发布脚本 + 图标源
|
||||
│ ├── assets/ # 生成的测试图案 PNG(gen_*.py 产物)
|
||||
│ ├── release/ # 打包 exe + 运行配置(入库);发布 zip(不入库)
|
||||
@@ -35,13 +36,13 @@ LT8619C_Debug_Tools/
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 发布流程(HDMI-Tool)
|
||||
## 发布流程(DLPilot)
|
||||
|
||||
```powershell
|
||||
cd HDMI-Tool/src
|
||||
python build_exe.py # 打包 → ../release/HDMI-Tool.exe
|
||||
python package_release.py <版本> # → ../release/HDMI-Tool-v<版本>-win64.zip
|
||||
python publish_gitea.py HDMI-Tool-v<版本>-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN)
|
||||
cd DLPilot/src
|
||||
python build_exe.py # 打包 → ../release/DLPilot.exe
|
||||
python package_release.py <版本> # → ../release/DLPilot-v<版本>-win64.zip
|
||||
python publish_gitea.py DLPilot-v<版本>-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN)
|
||||
```
|
||||
|
||||
## 备注
|
||||
|
||||