From cdef23b80a8b622b412210f8316a2ceaa5ff59f2 Mon Sep 17 00:00:00 2001 From: hulk Date: Fri, 11 Sep 2026 00:25:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(HDMI-Tool):=20v0.2.0=20=E9=9B=86=E6=88=90?= =?UTF-8?q?=E5=85=89=E6=9C=BA=E6=8E=A7=E5=88=B6(RS485=E2=86=92GD32?= =?UTF-8?q?=E7=BD=91=E5=85=B3=E2=86=92DLPC3421)=20=E2=80=94=20=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E6=8F=90=E4=BA=A4,=20=E5=BE=85=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E5=90=8E=E5=86=8D=E6=8E=A8=E9=80=81=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + HDMI-Tool/AGENTS.md | 64 +++++--- HDMI-Tool/README.md | 147 +++++++++++------ HDMI-Tool/src/dlpc_rs485.py | 250 +++++++++++++++++++++++++++++ HDMI-Tool/src/hdmi_gui.py | 269 +++++++++++++++++++++++++++++++- HDMI-Tool/src/selftest_rs485.py | 77 +++++++++ README.md | 4 +- 7 files changed, 732 insertions(+), 82 deletions(-) create mode 100644 HDMI-Tool/src/dlpc_rs485.py create mode 100644 HDMI-Tool/src/selftest_rs485.py diff --git a/.gitignore b/.gitignore index 3fbb6fe..7bf3aac 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ dist/ # 发布 zip: 可由 src/package_release.py 重新生成, 只上传 Gitea Release HDMI-Tool/release/*.zip + +# ZCode 客户端本地会话数据(计划留档等), 非项目配置 +.zcode/ diff --git a/HDMI-Tool/AGENTS.md b/HDMI-Tool/AGENTS.md index 4e4963d..3c825b8 100644 --- a/HDMI-Tool/AGENTS.md +++ b/HDMI-Tool/AGENTS.md @@ -7,9 +7,11 @@ ## 1. 项目定位 仓库 `LT8619C_Debug_Tools` 是 LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板的**调试上位机工具集**, -上游主仓库(固件)为 `hulk/LT8619C_DLPC3421_HDMI`(同 Gitea 实例 `gitea.hulk.wang`)。 -本工具工作在 HDMI **源端**(PC 显卡侧),与固件侧寄存器调试(I2C-Inject 工具)配合。 -寄存器语义判读基线(0x606E 位交换 / 0x606D 整组顺序)在上游仓库 `doc/architecture/`,不在本仓库。 +上游主仓库(固件)为 `hulk/LT8619C_DLPC3421_HDMI`(同 Gitea 实例 `gitea.hulk.wang`), +本仓库本机路径 `E:\Hulk-Coding\LT8619C_DLPC3421_HDMI`。 +HDMI-Tool 工作在两端:HDMI **源端**(PC 显卡侧,EDID/分辨率/图案)与**光机控制端** +(RS485 → 板载 GD32E230 网关 → DLPC3421)。寄存器语义判读基线(0x606E 位交换 / +0x606D 整组顺序)与光机协议规程都在上游仓库 `doc/architecture/` 与 `protocols.md`,不在本仓库。 ## 2. 目录契约(重要) @@ -30,8 +32,10 @@ HDMI-Tool/ | 文件 | 角色 | |---|---| -| `src/hdmi_gui.py` | 单文件主程序:GUI(tkinter) + 命令行,约 1500 行,纯标准库 | -| `src/build_exe.py` | PyInstaller 打包 → `release/HDMI-Tool.exe`(onefile/noconsole,内嵌图标) | +| `src/hdmi_gui.py` | 单文件主程序:GUI(tkinter) + 命令行 + 光机控制区, 纯标准库 + 可选依赖 | +| `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` | exe+图案+说明 → `release/HDMI-Tool-v-win64.zip`(校验栏位引用完整性) | | `src/publish_gitea.py` | 建/复用 Gitea Release 并上传 zip(tag 默认取 zip 文件名) | | `src/hdmi_source.ps1/.bat` | 独立 PowerShell 命令行工具(自包含,与 GUI 无代码共享) | @@ -39,33 +43,49 @@ HDMI-Tool/ | `src/gen_solid_colors.py` | 生成 `assets/solid_*.png` ×10(800x600 纯色,需 Pillow) | | `src/la_wave_analyzer*.py` | 逻辑分析仪 CSV 波形判读(2/3 通道,40MHz 像素时钟假设) | | `src/logos/make_ico.py` | svg/png → ico(写在其自身目录) | -| `release/pattern_slots.json` | 预置栏位:西门子星/波带片/综合测试卡,area=640x360 | +| `release/pattern_slots.json` | 预置栏位:西门子星/波带片/综合测试卡,area=640x360;`serial` 键记忆光机串口 | ## 3. 代码不变量(改 hdmi_gui.py 必须维持) -1. **纯标准库**:hdmi_gui.py 只准 import 标准库(ctypes/winreg/tkinter/…)。Pillow 是 - *可选*加速(仅扩展自定义图片格式 PNG/GIF 之外的 JPG/BMP/WebP),缺失时必须照常工作。 -2. **BASE_DIR 语义**:frozen(PyInstaller onefile)时 `BASE_DIR=exe 所在目录`(`sys.executable`), +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 **平铺**存放,不建子目录。 -3. **自定义栏位 `file` 字段是裸文件名**:加载时 `os.path.join(BASE_DIR, file)` 解析, +4. **自定义栏位 `file` 字段是裸文件名**:加载时 `os.path.join(BASE_DIR, file)` 解析, 因此 pattern_slots.json 引用的图片必须与 exe 同目录 —— package_release.py 正是按此平铺打包。 -4. **exe 自包含**:图标经 `--add-data` 内嵌(运行时从 `_MEIPASS` 读);内置五种图案 +5. **exe 自包含**:图标经 `--add-data` 内嵌(运行时从 `_MEIPASS` 读);内置五种图案 (红/绿/蓝/棋盘格/网格线)由程序绘制,运行时**不依赖** assets/;solid/bw PNG 只服务自定义栏位。 -5. **DPI 感知**:进程启动即 `SetProcessDpiAwareness(2)`,图案窗口按物理像素定位,勿移除。 -6. **启动强制扩展拓扑**:`SetDisplayConfig(SDC_TOPOLOGY_EXTEND|SDC_APPLY)`,静默容错 +6. **DPI 感知**:进程启动即 `SetProcessDpiAwareness(2)`,图案窗口按物理像素定位,勿移除。 +7. **启动强制扩展拓扑**:`SetDisplayConfig(SDC_TOPOLOGY_EXTEND|SDC_APPLY)`,静默容错 (失败不阻断启动)。动机:LT8619C 热插拔后 Windows 可能停留在"仅电脑屏幕"。 -7. **分辨率切换是临时的**:走 `CHANGEDISPLAYSETTINGS`(不写注册表),"恢复"用会话开始时快照。 -8. **EDID 来源是注册表**(`HKLM\SYSTEM\CurrentControlSet\Enum\DISPLAY\...\Device Parameters\EDID`), +8. **分辨率切换是临时的**:走 `CHANGEDISPLAYSETTINGS`(不写注册表),"恢复"用会话开始时快照。 +9. **EDID 来源是注册表**(`HKLM\SYSTEM\CurrentControlSet\Enum\DISPLAY\...\Device Parameters\EDID`), 不是 WMI/DDC;exe 的 noconsole 版无 stdout,`--dump/--edid-fields` 只在源码/控制台版可用。 +10. **光机串口线程回调不直接碰 GUI**:DLPC485 的 on_event/on_status 在后台线程触发, + 必须经 `queue.Queue` + `root.after(80, …)` 轮询投递回主线程(现行 `_poll_dlpc` 模式, + 与 I2C-Inject 的 Injector 同源);退出路径(关窗与 `--seconds` 自动关)都会走到 + `App.destroy()`,在那里 `dlpc.close()` 收线程。 +11. **pattern_slots.json 格式**:`{'area', 'slots', 'serial'}` 三键共存;load 兼容旧版 + 纯数组(无 area)与无 serial 键两种历史格式,save 永远写全三键。 ## 4. 领域语义(测试值都有含义,勿"顺手改") - 纯色值 55/AA/FF:55(01010101) 与 AA(10101010) 互为字节镜像对(检出位序颠倒/位交换), FF(11111111) 全高基准;R/G/B 三行对应 RGB888 三字节组(R=D[23:16]/G=D[15:8]/B=D[7:0])。 -- 800x600@60:demo 板 shadow EDID 首选时序(solid 系列分辨率与之一致)。 +- 800x600@60:LT8619C **demo 板** shadow EDID 首选时序(solid 系列分辨率与之一致)。 - 640x360:测试卡区域默认值(16:9 半分辨率,点对点检查用);full=铺满所选屏。 + **同时是转接板光机 DLPC3421 的 nHD 面板原生分辨率**(该板 shadow EDID 只声明 640x360@60), + 投 640x360 图案 + RS485 开光机 = 整链路 1:1 验证。 - bw 系列图案各自目的见 `src/gen_bw_patterns.py` 顶部清单注释。 +- 光机 RS485 命令集(M730/M731/M732/M201/M202/M737/M999/M888/M7/M8/M1)语义、 + 帧格式 `D5 01 LEN CRC`(累加和校验)、错误码与**就绪门禁** + (DLPC 未就绪时 M730/M731/M732 被拒回 0xFF)以上游 `protocols.md` 为唯一权威, + 本仓不维护协议文档副本;注意 **M7S0 是"开启"TEC 自动控温**(方向反直觉)、 + M737 在定时投光中延迟应答(CLI 等待上限 35s)。 ## 5. 构建 / 发布流水线(全部在 src/ 下执行) @@ -102,8 +122,10 @@ python publish_gitea.py HDMI-Tool-v<版本>-win64.zip --replace --insecure # ## 7. 验证清单(改完代码后) -1. `python hdmi_gui.py --seconds 5` —— GUI 冒烟(自动开关); -2. `python hdmi_gui.py --test-pattern Checker --area 800x600 --seconds 3` —— 投图链路; -3. `python hdmi_gui.py --dump` —— EDID 解算不抛异常; -4. `python build_exe.py` → 双击 release/HDMI-Tool.exe 确认图标/启动/投图; -5. `python package_release.py <版本>` —— 不报"引用图片缺失"即栏位完整性 OK。 +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。 diff --git a/HDMI-Tool/README.md b/HDMI-Tool/README.md index a6142c7..66dfb49 100644 --- a/HDMI-Tool/README.md +++ b/HDMI-Tool/README.md @@ -1,13 +1,14 @@ -# HDMI-Tool — HDMI 源端调试工具 +# HDMI-Tool — HDMI 源端调试 + 光机控制工具 > LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套的 PC 端工具: -> 在 HDMI **源端**(电脑显卡输出)完成 EDID 查看、分辨率切换、纯色/测试图案全屏投屏。 +> 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)。 -> - **要改代码/重新打包**:见 §7、§8。 +> - **要改代码/重新打包**:见 §8、§9。 > -> 更新:2026-09-10 +> 更新:2026-09-11 --- @@ -19,9 +20,10 @@ | EDID 逐字节解析 | "EDID 原始数据"框右上"逐字节解析"按钮:按偏移逐字段展开基础块与 CTA-861 扩展块,每字段附原始字节 hex;命令行等价 `--edid-fields` | | 分辨率切换 | "分辨率"下拉列出显卡按该屏 EDID 提供的全部模式;**应用**=临时切换(不写注册表,重启/恢复即还原),**恢复**=回到本次会话切改前的模式 | | 测试图案投屏 | 全屏投到所选显示器,测试卡区域可选 `640x360 / 800x600 / full 铺满`;内置 纯红/纯绿/纯蓝/黑白棋盘格/网格线 + R/G/B 三行 55/AA/FF 纯色值按钮 + 3 个自定义图片栏位 | +| 光机控制 | RS485 → 板载 GD32E230 网关 → DLPC3421:开关光机(可定时)、设光强、查电流/温度/版本、TEC 控制、软件重启;收发日志带协议译码(见 §5) | | 显示拓扑控制 | 启动时强制扩展显示拓扑(复制/仅第二屏等模式下保证 demo 板屏幕独立可控) | -| 命令行模式 | 全部功能可脚本化调用,供自动化测试(见 §5、§6) | -| 波形分析脚本 | 逻辑分析仪 CSV 波形按占空比签名分段,输出行结构/占空比/消隐明细(见 §6.3) | +| 命令行模式 | 全部功能可脚本化调用,供自动化测试(见 §6、§7) | +| 波形分析脚本 | 逻辑分析仪 CSV 波形按占空比签名分段,输出行结构/占空比/消隐明细(见 §7.3) | ## 2. 下载与安装 @@ -34,8 +36,8 @@ ``` HDMI-Tool/ -├── HDMI-Tool.exe 主程序(单文件,自包含) -├── pattern_slots.json 预置自定义栏位(指向下面的 bw 图案) +├── HDMI-Tool.exe 主程序(单文件自包含, 光机控制的 pyserial 已内置) +├── pattern_slots.json 预置自定义栏位 + 串口记忆 ├── bw_*.png ×5 640x360 黑白测试图 ├── solid_*.png ×10 800x600 纯色测试图 └── README.md 本说明 @@ -43,49 +45,77 @@ HDMI-Tool/ ## 3. GUI 使用 -主窗口自上而下三块区域: +主窗口自上而下四块区域: ``` -┌ 显示器: [GDI ... ▼] [刷新] [拓扑: 扩展 ▼] ┐ -├ 分辨率: [800x600@60 ▼] [应用] [恢复] ┐ -├ ── EDID 原始数据 ──────────── [逐字节解析] ─┤ -│ (hex 文本框) │ -├ 测试卡区域: (○640x360 ○800x600 ○full) ┤ -├ [纯红][纯绿][纯蓝][棋盘格][网格线] │ ← 图案按钮 -├ R: [55][AA][FF] G: [55][AA][FF] B: [55][AA][FF] │ ← 纯色值按钮 -├ [自定义1][自定义2][自定义3] [⚙] │ ← 自定义栏位 -└─────────────────────────────────────────────┘ +┌ 显示器: [ ... ▼] [刷新] ┐ +├ 分辨率: [800x600@60 ▼] [应用] [恢复] ├ 显示器/EDID +│ 支持的模式(EDID 解算) | EDID 信息 ┘ +├ 测试图案 — 图案按钮 / R,G,B 0x55,0xAA,0xFF / 测试卡区域 / 自定义栏位 ┐ +├ 光机控制 — 串口 [连接] / 开关光机 / 光强 / 命令下拉 / 自定义命令 ┘ 投屏+光机 +│ └ 收发日志(带协议译码与原始 hex) +└ EDID 原始数据 — hex dump [逐字节解析] ┘ ``` 1. 顶部显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举; -2. **切分辨率**:第二行选模式 → "应用";测完点"恢复"; -3. **投测试图**: - - 先在图案按钮行上方选**测试卡区域**(640x360 / 800x600 / full); - - 点任意图案按钮即全屏投放;按 **ESC** 或点击退出; - - R/G/B 三行纯色按钮(55/AA/FF)用于 RGB 位交换类测试:55(01010101) 与 - AA(10101010) 互为字节镜像对,FF(11111111) 为全高基准; - - 自定义图片:点"⚙"配置名称与图片文件(图片会自动复制到 exe 旁边),点栏位名投放; -4. 测试卡区域与自定义栏位配置自动保存在 exe 旁的 `pattern_slots.json`。 +2. **切分辨率**:选模式 → "应用";测完点"恢复"; +3. **投测试图**:先选**测试卡区域**(640x360 / 800x600 / full)→ 点图案按钮全屏投放, + 按 **ESC** 或点击退出;R/G/B 三行纯色按钮(55/AA/FF)用于 RGB 位交换类测试 + (55 与 AA 互为字节镜像对,FF 全高基准);自定义图片点"⚙"配置(图片自动复制到 exe 旁); +4. 区域与自定义栏位配置自动保存在 exe 旁的 `pattern_slots.json`。 ## 4. 典型测试场景 ``` -1. HDMI 接 demo 板 → 启动 HDMI-Tool → 刷新 → 选中 demo 板显示器 -2. 分辨率切 800x600@60(demo 板 shadow EDID 首选)→ 测试卡区域选 800x600 -3. 点 R/G/B 行纯色按钮投图 → 配合逻辑分析仪/万用表测转接板 RGB888 输出 +1. HDMI 接转接板 → 启动 HDMI-Tool → 刷新 → 选中板卡显示器 +2. 板卡 shadow EDID 首选 640x360@60 → 测试卡区域选 640x360(与光机面板原生 1:1) +3. "光机控制"连接串口 → M999 查版本确认链路通 → M730S0 开光机 +4. 点 R/G/B 行纯色按钮投图 → 目视/逻辑分析仪核对 RGB888 输出 - 纯红/绿/蓝:验证通道映射(R=D[23:16], G=D[15:8], B=D[7:0]) - 55/AA 对:验证位交换(0x606E)——图像应为 50% 灰阶占空比 -4. 换 bw 图案(西门子星/波带片)看极限分辨率与混叠 -5. 测完"恢复"分辨率 +5. M731S100 设光强 → M732 查电流读回复核 → M730S1 关光机 ``` -寄存器语义(0x606E 位交换 / 0x606D 整组顺序)的判读基线见上游主仓库 -[LT8619C_DLPC3421_HDMI](https://gitea.hulk.wang/hulk/LT8619C_DLPC3421_HDMI) 的 -`doc/architecture/`。 +寄存器语义(0x606E 位交换 / 0x606D 整组顺序)与光机协议的判读基线见上游主仓库 +[LT8619C_DLPC3421_HDMI](https://gitea.hulk.wang/hulk/LT8619C_DLPC3421_HDMI) 的 `doc/architecture/`。 -## 5. 命令行模式(hdmi_gui.py) +## 5. 光机控制(RS485 → GD32 网关 → DLPC3421) -exe 版不支持 `--dump/--edid-fields`(noconsole 无控制台输出),请用源码方式跑: +物理链路:PC 经 **USB-RS485 转换器**接板卡 A/B 端子,板上 GD32E230 做 RS485→I2C 网关 +控制 DLPC3421(I2C 地址 0x1B)。串口参数 **115200, 8N1**,协议细节以上游固件仓 +`protocols.md` 为准:命令帧 `D5 01 LEN CRC`、响应帧 `B5 TYPE LEN CRC`, +CRC 为第 2 字节起累加和取低 8 位。 + +GUI"光机控制"区:选串口 → 连接 → 点快捷按钮或命令下拉/自定义输入发送;日志区实时显示 +`→ 命令` / `← 译码结果 (原始 hex)`,错误自动附释义(如 `err:3(I2C NACK)`)。 + +| 命令 | 功能 | 响应示例 | +|---|---|---| +| `M730S0` / `M730S1` | 开 / 关光机 | `ok` | +| `M730S0T2000` | 开光机并定时 2000ms 自动关(≤32700) | `ok`(期间 M737 延迟应答) | +| `M731S100` | 设光强 54-800(RGB 同值,写后读回复核) | `ok` / `err:R..G..B..` | +| `M732` | 查三路电流设置值(0-65535) | `okR110G110B110` | +| `M201` / `M202` | 查 DMD / LAMP 温度(0.1°C) | `okTL:246` = 24.6°C | +| `M999` | 查固件版本 | `okv20.0.0.1` | +| `M737` | 查投光是否结束(投光中延迟应答) | `ok` | +| `M7S0` / `M7S1` | TEC 自动控温开 / 关(注意 S0=开) | `ok` | +| `M8S330` | TEC 凝露点 33.0°C(200-350,0.1°C) | `ok` / `err:OL` | +| `M1` | 协议握手 | `ok` | +| `M888` | 软件重启 | `ok` 后 100ms 复位 | + +注意:**DLPC3421 启动完成前 M730/M731/M732 被就绪门禁拒绝**(回 0xFF),上电后稍等几秒再发。 + +命令行等价(源码方式): + +``` +python hdmi_gui.py --port COM9 --dlpc-cmd M730S0 --dlpc-cmd M999 + 逐条发送并打印译码响应 +``` + +## 6. 命令行模式(hdmi_gui.py) + +exe 版无控制台,`--dump/--edid-fields` 的输出不可见(`--dlpc-cmd` 仍可执行,靠退出码判断), +调试请用源码方式: ``` python hdmi_gui.py --dump 控制台输出 EDID 解算 @@ -93,14 +123,16 @@ python hdmi_gui.py --edid-fields 控制台输出 EDID 逐字 python hdmi_gui.py --test-pattern Blue --area 800x600 --seconds 5 出图 5 秒自动退出 python hdmi_gui.py --test-pattern Custom1 投自定义栏位 1 +python hdmi_gui.py --port COM9 --dlpc-cmd M730S0 RS485 发光机命令(可多次) python hdmi_gui.py --seconds 5 GUI 冒烟测试, 5 秒自动关 --test-pattern {Red,Green,Blue,Checker,Grid,Custom1..3} --area {640x360,800x600,full} --monitor N --seconds S + --port COMx --baud 115200 --dlpc-cmd CMD(可重复; 任一命令失败退出码 1) ``` -## 6. 辅助脚本(src/ 下) +## 7. 辅助脚本(src/ 下) -### 6.1 hdmi_source.bat / .ps1 — 源端命令行工具 +### 7.1 hdmi_source.bat / .ps1 — 源端命令行工具 与 GUI 互补,适合脚本串联: @@ -111,7 +143,7 @@ tools\hdmi_source.bat -Mode 800x600 -Pattern -Restore 切模式→出图案→退出后自动恢复 ``` -### 6.2 gen_bw_patterns.py / gen_solid_colors.py — 测试图生成 +### 7.2 gen_bw_patterns.py / gen_solid_colors.py — 测试图生成 需 Pillow(`py -m pip install pillow`)。重新生成 `assets/` 下全部图案: @@ -122,7 +154,7 @@ tools\hdmi_source.bat -Mode 800x600 -Pattern -Restore - `gen_solid_colors.py` → 800x600 纯色图 ×10(R/G/B 各 55/AA/FF + 三原色), 与 GUI 内置纯色按钮同源,供自定义栏位或其他工具加载。 -### 6.3 la_wave_analyzer.py / la_wave_analyzer3.py — 逻辑分析仪波形判读 +### 7.3 la_wave_analyzer.py / la_wave_analyzer3.py — 逻辑分析仪波形判读 分析 Kingst VIS 导出的边沿流 CSV(列:`Time[s], 通道A, 通道B[, 通道C]`, 10ns 分辨率),按 50ms 窗口占空比签名自动分段,输出每段的签名(FF/55/AA/00)、 @@ -133,12 +165,19 @@ python la_wave_analyzer.py # 2 通道(默认判读 B 口 D0/ python la_wave_analyzer3.py # 3 通道(RGB 三组同拍, ch8/9/10) ``` -## 7. 目录结构与从源码运行 +### 7.4 selftest_rs485.py — 光机协议自检(无硬件) + +对照上游 `protocols.md` 实测帧向量回归帧构造/解析/译码:`python selftest_rs485.py`, +全部通过退出码 0。改 `dlpc_rs485.py` 后必须跑一遍。 + +## 8. 目录结构与从源码运行 ``` HDMI-Tool/ ├── src/ # 源代码(人工维护区) -│ ├── hdmi_gui.py # 主程序:GUI + 命令行, 纯标准库 +│ ├── hdmi_gui.py # 主程序:GUI + 命令行(标准库; pyserial/Pillow 可选) +│ ├── dlpc_rs485.py # 光机 RS485 协议与串口链路(帧/解析/译码/线程) +│ ├── selftest_rs485.py # 光机协议自检(无硬件回归) │ ├── build_exe.py # PyInstaller 打包 → release/ │ ├── package_release.py # 组装发布 zip(exe+图案+说明) │ ├── publish_gitea.py # 上传到 Gitea Release @@ -152,28 +191,32 @@ HDMI-Tool/ │ └── solid_*.png ×10 # 800x600 纯色系列 ├── release/ # 构建与发布产物(机器生成区, 不手改) │ ├── HDMI-Tool.exe # 打包单文件版(改动跟随源码提交入库) -│ ├── pattern_slots.json # exe 运行配置(默认栏位预置 bw 图案) +│ ├── pattern_slots.json # exe 运行配置(栏位预置 + 串口记忆) │ └── *.zip # 发布包(不入库, 上传 Gitea Release) ├── README.md # 本说明 └── AGENTS.md # 面向 AI/Agent 的工程档案(结构/约定/构建规程) ``` -从源码运行:`python src/hdmi_gui.py`,需 Python ≥3.10,纯标准库无第三方依赖 -(自定义图片要 JPG/BMP/WebP 时需可选 Pillow)。 +从源码运行:`python src/hdmi_gui.py`,需 Python ≥3.10,标准库即可跑 EDID/投屏; +光机控制需 pyserial(`py -m pip install pyserial`),自定义图片要 JPG/BMP/WebP 需可选 Pillow。 -## 8. 构建与发布 +## 9. 构建与发布 ```powershell cd src -python build_exe.py # 打包 → ../release/HDMI-Tool.exe(自动装 PyInstaller) -python build_exe.py --debug # 额外出一个带控制台的调试变体(--dump 可用) -python package_release.py 0.1.0 # 组装 release/HDMI-Tool-v0.1.0-win64.zip -python publish_gitea.py HDMI-Tool-v0.1.0-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN) +python selftest_rs485.py # 光机协议回归(改 dlpc_rs485.py 后必跑) +python build_exe.py # 打包 → ../release/HDMI-Tool.exe(自动装 PyInstaller; 内置 pyserial) +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) ``` -## 9. 注意事项 / FAQ +## 10. 注意事项 / FAQ -- **exe 放只读目录会怎样**:自定义图片与 `pattern_slots.json` 写在 exe 旁边,只读目录会导致栏位配置存不下; +- **光机没反应排查**:① 串口/波特率(115200 8N1)与 A/B 接线;② 先发 `M999` 验证链路; + ③ 开光机回 0xFF 是 DLPC 就绪门禁——上电后等几秒;④ USB-RS485 转换器半双工, + 发送时板上回包不会回环(正常); +- **exe 放只读目录会怎样**:自定义图片与 `pattern_slots.json` 写在 exe 旁边,只读目录会导致配置存不下; - **重新打包后图标没变**:Windows 图标缓存,`ie4uinit -show` 或改个名即可; - **多屏序号**:切分辨率前先确认显示器序号(GUI 下拉框与 `hdmi_source.bat -List` 输出一致); - **残留进程**:修改源码重新打包前先结束残留的 HDMI-Tool 进程(`build_exe.py` 会自动 taskkill); diff --git a/HDMI-Tool/src/dlpc_rs485.py b/HDMI-Tool/src/dlpc_rs485.py new file mode 100644 index 0000000..d78bd15 --- /dev/null +++ b/HDMI-Tool/src/dlpc_rs485.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +DLPC3421 光机 RS485 控制链路(板上 GD32E230 作 RS485→I2C 网关) +================================================================ +协议以 ../LT8619C_DLPC3421_HDMI/protocols.md 为准(上游固件仓, 含逐条命令的 +响应语义/错误码/就绪门禁与实测帧示例): + 物理层 RS485 半双工 115200 8N1(PC 经 USB-RS485 转换器接板, DE 由 MCU 硬件换向) + 命令帧 D5 01 LEN CRC 01=板类型, LEN=命令字节数 + 响应帧 B5 TYPE LEN CRC payload 为 ASCII(ok / err:OL / okTL:246 ...) + CRC 帧内第 2 字节(板类型)起累加到最后一个 payload 字节, 取低 8 位 + 帧长 最小 6 字节, 最大 32 字节 +就绪门禁: DLPC3421 启动完成前 M730/M731/M732 被拒(回 0xFF), 属预期。 + +常用命令见 PRESETS(全表见 protocols.md); M7S0 是"开启"TEC 自动控温, 方向别记反。 + +仅标准库 + 可选 pyserial(缺失只影响串口功能, 同 hdmi_gui.py 的 Pillow 模式)。 +""" + +import collections +import queue +import re +import threading +import time + +try: + import serial + import serial.tools.list_ports + HAS_SERIAL = True +except ImportError: + HAS_SERIAL = False + +HEADER_TX = 0xD5 # 命令帧头(Host -> Device) +HEADER_RX = 0xB5 # 响应帧头(Device -> Host) +BOARD_TYPE = 0x01 # 板类型字节 +FRAME_MIN, FRAME_MAX = 6, 32 # 整帧长度上下限(含帧头/CRC) +DEFAULT_BAUD = 115200 + +# --- 响应 TYPE 码语义(protocols.md "响应 TYPE 码"表) --- +TYPE_NAMES = { + 0xF0: '成功', + 0xF1: 'CRC 校验错', + 0xF2: '包头错', + 0xF3: '板类型错', + 0xF4: '格式/缺参数', + 0xFD: '参数越界/读回复核不一致', + 0xFE: '未知命令', + 0xFF: '通用错误(含 DLPC 就绪门禁拒绝)', + 0xFA: '开光机通信失败', + 0xFB: '关光机通信失败', + 0xFC: '读回复核通信失败', +} + +# --- payload 中 err: 的 DLPC 侧错误码 --- +DLPC_ERR_NAMES = { + 0: 'DLPC 成功', + 1: 'DLPC 通用错误', + 2: 'DLPC 通信超时', + 3: 'I2C NACK(器件无应答)', + 4: 'DLPC 参数无效', + 5: 'DLPC 忙', +} + +# GUI 预设命令表: (显示名, 命令, 说明), 顺序即下拉顺序 +PRESETS = ( + ('开光机', 'M730S0', '开启 RGB 投光(需 DLPC 就绪)'), + ('关光机', 'M730S1', '关闭投光并停定时器'), + ('设光强', 'M731S100', 'RGB 三路同值, 范围 54-800, 写后读回复核'), + ('查电流', 'M732', 'okRGB, 三路电流原始值 0-65535'), + ('查DMD温度', 'M201', 'okTL:246 = 24.6°C(有符号, 0.1°C)'), + ('查LAMP温度', 'M202', '同 M201'), + ('查投光结束', 'M737', '定时投光中不立即回包, 到期自动关灯后补发 ok'), + ('查版本', 'M999', 'okv20.0.0.1 形式'), + ('握手', 'M1', '协议握手, 回 ok'), + ('TEC自动控温开', 'M7S0', 'S0=开启 PID 自动控温(方向别记反)'), + ('TEC自动控温关', 'M7S1', 'S1=关闭'), + ('凝露点33.0°C', 'M8S330', 'TEC 凝露点, 0.1°C 单位, 范围 200-350'), + ('软件重启', 'M888', '回 ok 后 100ms 复位'), +) + +Frame = collections.namedtuple('Frame', 'type_code payload raw') + + +def sum_crc(data): + """帧内第 2 字节(板类型)起累加到末 payload 字节, 取低 8 位(不含包头与 CRC)""" + return sum(data) & 0xFF + + +def build_frame(cmd): + """ASCII 命令字符串 -> 命令帧 bytes; 空命令/超长/非 ASCII 抛 ValueError""" + payload = cmd.strip().encode('ascii') + if not payload: + raise ValueError('空命令') + if len(payload) > FRAME_MAX - 4: + raise ValueError(f'命令过长(>{FRAME_MAX - 4} 字节, 帧上限 {FRAME_MAX})') + frame = bytes((HEADER_TX, BOARD_TYPE, len(payload))) + payload + return frame + bytes((sum_crc(frame[1:]),)) + + +class FrameParser: + """增量字节流 -> 完整响应帧: 帧头+LEN 定界, CRC 校验, 失配丢当前帧头重新同步。 + + payload 理论上可能含 0xB5 造成同步偏移(伪帧头 CRC 必错会被跳过), + 下一帧自动恢复, 对调试工具可接受。 + """ + + def __init__(self): + self._buf = bytearray() + + def feed(self, data): + """喂入新收到的字节, 返回本次解析出的完整 Frame 列表""" + self._buf += data + frames = [] + while True: + i = self._buf.find(HEADER_RX) + if i < 0: + self._buf.clear() + return frames + if i: # 丢弃帧头前的噪声 + del self._buf[:i] + if len(self._buf) < 3: # 帧头/TYPE/LEN 尚不完整 + return frames + length = self._buf[2] + if not FRAME_MIN - 4 <= length <= FRAME_MAX - 4: + del self._buf[:1] # LEN 非法 -> 伪帧头, 前进 1 字节重同步 + continue + total = 4 + length + if len(self._buf) < total: # 整帧未收齐 + return frames + raw = bytes(self._buf[:total]) + if raw[-1] == sum_crc(raw[1:-1]): + frames.append(Frame(self._buf[1], + raw[3:-1].decode('ascii', 'replace').strip(), raw)) + del self._buf[:total] + else: # CRC 失配 -> 丢帧头重同步 + del self._buf[:1] + + +def describe(fr): + """响应帧 -> 人读文本: TYPE 译码 + payload, err:N 追加 DLPC 错误释义""" + if fr.type_code == 0xF0: + text = fr.payload or 'ok' + else: + text = TYPE_NAMES.get(fr.type_code, f'TYPE=0x{fr.type_code:02X}') + if fr.payload: + text += f' {fr.payload}' + m = re.search(r'err:(\d+)', fr.payload) + if m: + text += f'({DLPC_ERR_NAMES.get(int(m.group(1)), "未知错误码")})' + return text + + +class DLPC485: + """串口工作线程: 端口/波特率热切换 + 命令队列 + 回调(收发全在后台线程)。 + + on_event(str) 收发日志行(已译码, 附原始 hex) + on_status(str) 连接状态变化(已连接/断开/失败/设备拔出) + 回调在后台线程触发, GUI 侧需自行投递回主线程(见 hdmi_gui 的轮询队列)。 + """ + + 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.ser = None + self.parser = FrameParser() + self._lock = threading.Lock() + threading.Thread(target=self._run, daemon=True).start() + + @staticmethod + def list_ports(): + """枚举本机串口名(未装 pyserial 返回空表)""" + if not HAS_SERIAL: + return [] + return [p.device for p in serial.tools.list_ports.comports()] + + def open(self, port, baud=DEFAULT_BAUD): + if not HAS_SERIAL: + self.on_status('未安装 pyserial: py -m pip install pyserial') + return + with self._lock: + self._target = (port, int(baud)) + + def close(self): + with self._lock: + self._target = None + + def is_connected(self): + with self._lock: + return self.ser is not None + + def send(self, cmd): + """发送一条 ASCII 命令(自动组帧); 未连接/命令非法走 on_status 提示""" + with self._lock: + ser = self.ser + if ser is None: + self.on_status('未连接串口, 命令被丢弃') + return + try: + frame = build_frame(cmd) + except ValueError as e: + self.on_status(f'命令非法: {e}') + return + self.q.put((cmd, frame)) + + def _run(self): + while True: + with self._lock: + target = self._target + cur = (self.ser.port, self.ser.baudrate) if self.ser else None + if target != cur: # 端口/波特率变化或断开: 统一在此切换 + if self.ser is not None: + try: + self.ser.close() + except Exception: + pass + self.ser = None + self.on_status('已断开') + if target: + try: + self.ser = serial.Serial(target[0], target[1], timeout=0.15) + self.parser = FrameParser() + self.on_status(f'已连接 {target[0]} @ {target[1]}') + except Exception as e: + self.ser = None + with self._lock: + self._target = None + self.on_status(f'连接失败: {e}') + if self.ser is not None: + try: + item = None + try: + item = self.q.get_nowait() + except queue.Empty: + pass + if item is not None: + cmd, frame = item + self.ser.write(frame) + self.on_event(f'→ {cmd} [{frame.hex(" ").upper()}]') + data = self.ser.read(256) + if data: + for fr in self.parser.feed(data): + self.on_event(f'← {describe(fr)} ' + f'[{fr.raw.hex(" ").upper()}]') + except OSError: + self.ser = None + with self._lock: + self._target = None + self.on_status('串口丢失(设备拔出?), 请重新连接') + time.sleep(0.02) diff --git a/HDMI-Tool/src/hdmi_gui.py b/HDMI-Tool/src/hdmi_gui.py index 6204229..bab6a00 100644 --- a/HDMI-Tool/src/hdmi_gui.py +++ b/HDMI-Tool/src/hdmi_gui.py @@ -6,15 +6,18 @@ HDMI EDID 查看器 + 测试图案投屏工具 配合 LT8619C(HDMI 转 RGB888)→ DLPC3421 转接板调试使用。 功能: - 1. 枚举各显示输出口接的设备, 从注册表读取其 EDID 原始数据, + 1. 枚举各显示输出口, 从注册表读取其 EDID 原始数据, 按 EDID 1.4 / CEA-861 标准解算: 厂商/产品/名称/尺寸/Gamma/输入类型、 支持的全部模式(详细时序 DTD / 已建立时序 / 标准时序 / CEA VIC)、 首选时序完整参数(像素时钟/消隐/同步极性)、CEA 扩展能力、校验和。 2. 向所选屏幕全屏投测试图案(黑底 1:1 居中, 测试卡区域可选 640x360 / 800x600 / full 铺满): 纯红 / 纯绿 / 纯蓝 / 黑白棋盘格 / 网格线 + 3 个自定义图片栏位, 点击或 ESC 退出。 + 3. 光机控制: RS485 直连板上 GD32E230 网关(→DLPC3421), 开关光机/设光强/ + 查温度电流/定时投光, 协议见 dlpc_rs485.py 头注释(上游仓 protocols.md)。 -仅用标准库(ctypes / winreg / tkinter), 无需 pip 安装任何依赖。 +仅用标准库(ctypes / winreg / tkinter), 无需 pip 安装任何依赖; +可选 pyserial(光机控制)与 Pillow(自定义图片 JPG/BMP/WebP), 缺了不影响其余功能。 命令行模式(自动化测试用): python hdmi_gui.py 启动 GUI @@ -24,6 +27,8 @@ HDMI EDID 查看器 + 测试图案投屏工具 直接出图案, 3 秒自动退出 python hdmi_gui.py --test-pattern Blue --area 800x600 指定测试卡区域(full=铺满所选屏) + python hdmi_gui.py --port COM9 --dlpc-cmd M730S0 --dlpc-cmd M999 + RS485 逐条发光机命令并打印译码响应 python hdmi_gui.py --seconds 5 GUI 5 秒自动关闭(冒烟测试) """ @@ -33,11 +38,15 @@ import ctypes.wintypes as wt import json import math import os +import queue import shutil import struct import sys +import time import winreg +import dlpc_rs485 + # 进程级 DPI 感知: 保证图案窗口按物理像素精确定位/绘制 try: ctypes.windll.shcore.SetProcessDpiAwareness(2) @@ -65,11 +74,15 @@ SLOT_COUNT = 3 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} + def load_slots(): """读 pattern_slots.json, 返回 (slots, area)。 - 新格式为 {'area': 'WxH'|'full', 'slots': [...]}; 兼容旧版纯数组格式(无 area)。 + 新格式为 {'area': 'WxH'|'full', 'slots': [...], 'serial': {...}}; + 兼容旧版纯数组格式(无 area)与无 serial 键的中间格式。 """ slots = [{'name': f'自定义{i + 1}', 'file': None} for i in range(SLOT_COUNT)] try: @@ -80,6 +93,13 @@ def load_slots(): 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 else: area, items = DEFAULT_AREA, data for i, item in enumerate(items[:SLOT_COUNT]): @@ -91,7 +111,8 @@ def load_slots(): def save_slots(slots, area): with open(SLOT_CONFIG, 'w', encoding='utf-8') as f: - json.dump({'area': area, 'slots': slots}, f, ensure_ascii=False, indent=2) + json.dump({'area': area, 'slots': slots, 'serial': SERIAL_CFG}, + f, ensure_ascii=False, indent=2) def copy_image_into_script_dir(src_path): @@ -1008,8 +1029,8 @@ def run_gui(auto_close=0): class App(tk.Tk): def __init__(self): super().__init__() - self.title('HDMI-Tool — EDID 查看器 + 测试图案 (LT8619C 转接板调试)') - self.geometry('960x880') + self.title('HDMI-Tool — EDID 查看器 + 测试图案 + 光机控制 (LT8619C 转接板调试)') + self.geometry('960x1100') ic = get_icon_path() if ic: try: @@ -1020,6 +1041,10 @@ def run_gui(auto_close=0): self.slots, self.area = 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._build() def _build(self): @@ -1113,6 +1138,85 @@ def run_gui(auto_close=0): ttk.Label(row2, text='自定义: 点击投图, ⚙ 配置名称与图片(图片保存在脚本目录)', foreground='#666666').pack(side='left', padx=10) + # --- 光机控制: RS485 -> 板载 GD32E230 网关 -> DLPC3421 --- + grp2 = ttk.LabelFrame( + self, text='光机控制 — RS485 → 板载 GD32 网关 → DLPC3421 (115200 8N1)', + padding=(10, 6)) + grp2.pack(fill='x', padx=8, pady=(6, 0)) + if not dlpc_rs485.HAS_SERIAL: + ttk.Label(grp2, foreground='#B05000', + text='未安装 pyserial, 光机控制不可用 — ' + '执行 py -m pip install pyserial 后重启本工具').pack(anchor='w') + else: + r1 = ttk.Frame(grp2) + r1.pack(fill='x') + ttk.Label(r1, text='串口:').pack(side='left') + self.cbo_dlpc = ttk.Combobox(r1, width=12, state='readonly', + values=dlpc_rs485.DLPC485.list_ports()) + if SERIAL_CFG['port']: + self.cbo_dlpc.set(SERIAL_CFG['port']) + self.cbo_dlpc.pack(side='left', padx=(4, 2)) + ttk.Button(r1, text='刷新', width=6, + command=self._dlpc_refresh).pack(side='left') + ttk.Label(r1, text='波特率:').pack(side='left', padx=(10, 0)) + self.var_baud = tk.StringVar(value=str(SERIAL_CFG['baud'])) + ttk.Combobox(r1, width=8, textvariable=self.var_baud, + values=('115200', '57600', '38400', '9600') + ).pack(side='left', padx=4) + self.btn_dlpc = ttk.Button(r1, text='连接', width=8, + command=self._dlpc_toggle) + self.btn_dlpc.pack(side='left', padx=(10, 0)) + self.lbl_dlpc_st = ttk.Label(r1, text='未连接', foreground='#666666') + self.lbl_dlpc_st.pack(side='left', padx=10) + + r2 = ttk.Frame(grp2) + r2.pack(fill='x', pady=(4, 0)) + for name, cmd in (('开光机', 'M730S0'), ('关光机', 'M730S1'), + ('查电流', 'M732'), ('查版本', 'M999'), + ('软件重启', 'M888')): + ttk.Button(r2, text=name, width=9, + command=lambda c=cmd: self._dlpc_send(c) + ).pack(side='left', padx=3) + ttk.Label(r2, text='光强(54-800):').pack(side='left', padx=(12, 2)) + self.var_light = tk.StringVar(value='100') + ttk.Entry(r2, textvariable=self.var_light, width=6).pack(side='left') + ttk.Button(r2, text='设光强', width=7, + command=self._dlpc_set_light).pack(side='left', padx=4) + + r3 = ttk.Frame(grp2) + r3.pack(fill='x', pady=(4, 0)) + ttk.Label(r3, text='命令:').pack(side='left') + self._dlpc_presets = [(n, c) for n, c, _ in dlpc_rs485.PRESETS] + self.cbo_dlpc_cmd = ttk.Combobox( + r3, state='readonly', width=26, + values=[f'{n} {c}' for n, c in self._dlpc_presets]) + self.cbo_dlpc_cmd.current(0) + self.cbo_dlpc_cmd.pack(side='left', padx=(4, 2)) + ttk.Button(r3, text='发送', width=7, + command=self._dlpc_send_preset).pack(side='left') + ttk.Label(r3, text='自定义:').pack(side='left', padx=(12, 2)) + self.var_custom_cmd = tk.StringVar() + ent_cmd = ttk.Entry(r3, textvariable=self.var_custom_cmd, width=14) + ent_cmd.pack(side='left') + ent_cmd.bind('', lambda _e: self._dlpc_send_custom()) + ttk.Button(r3, text='发送', width=7, + command=self._dlpc_send_custom).pack(side='left', padx=4) + ttk.Label(r3, text='如 M730S0T2000(定时 2 秒投光)', + foreground='#666666').pack(side='left', padx=6) + + r4 = ttk.Frame(grp2) + r4.pack(fill='x', pady=(4, 0)) + self.txt_dlpc = tk.Text(r4, height=4, font=('Consolas', 9), + state='disabled', wrap='none', + background='#101010', foreground='#C8C8C8') + sb4 = ttk.Scrollbar(r4, orient='vertical', command=self.txt_dlpc.yview) + self.txt_dlpc.configure(yscrollcommand=sb4.set) + self.txt_dlpc.pack(side='left', fill='both', expand=True) + sb4.pack(side='right', fill='y') + self._dlpc_log('帧格式: D5 01 LEN <命令> CRC / B5 TYPE LEN CRC, ' + '累加和校验; 全部命令见 README"光机控制"') + self.after(80, self._poll_dlpc) + # --- 底部: EDID hex dump + 逐字节解析按钮 --- bot = ttk.LabelFrame(self, text='EDID 原始数据', padding=4) bot.pack(fill='both', expand=False, padx=8, pady=(6, 8)) @@ -1181,6 +1285,104 @@ def run_gui(auto_close=0): self.area = self.area_var.get() save_slots(self.slots, self.area) + # ---- 光机控制(RS485) ---- + def _dlpc_on_event(self, line): + self._dlpc_log_q.put(('event', line)) + + 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 = '#2E7D32' + elif ('失败' in text or '丢失' in text or '未' in text + or '非法' in text or '丢弃' in text): + color = '#B05000' + else: + color = '#666666' + self.lbl_dlpc_st.config(text=text, foreground=color) + 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.config(text='断开' if connected else '连接') + 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]) > 300: + self.txt_dlpc.delete('1.0', '2.0') # 只保留最近约 300 行 + self.txt_dlpc.see('end') + self.txt_dlpc.configure(state='disabled') + + def _dlpc_refresh(self): + ports = dlpc_rs485.DLPC485.list_ports() + self.cbo_dlpc['values'] = ports + if SERIAL_CFG['port'] in ports: + self.cbo_dlpc.set(SERIAL_CFG['port']) + elif ports: + self.cbo_dlpc.current(0) + + def _dlpc_toggle(self): + if self.dlpc.is_connected(): + self.dlpc.close() + return + port = self.cbo_dlpc.get().strip() + if not port: + self.lbl_dlpc_st.config(text='未选择串口', foreground='#B05000') + return + try: + baud = int(self.var_baud.get()) + except ValueError: + self.lbl_dlpc_st.config(text='波特率非法', foreground='#B05000') + return + SERIAL_CFG['port'], SERIAL_CFG['baud'] = port, baud + save_slots(self.slots, self.area_var.get()) # 记住本次串口 + self.dlpc.open(port, baud) + + def _dlpc_send(self, cmd): + if not self.dlpc.is_connected(): + self.lbl_dlpc_st.config(text='未连接串口', foreground='#B05000') + return + self.dlpc.send(cmd) + + def _dlpc_send_preset(self): + i = self.cbo_dlpc_cmd.current() + if i >= 0: + self._dlpc_send(self._dlpc_presets[i][1]) + + def _dlpc_send_custom(self): + cmd = self.var_custom_cmd.get().strip() + if cmd: + self._dlpc_send(cmd) + + def _dlpc_set_light(self): + from tkinter import messagebox + 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 destroy(self): + try: # 两种退出路径(关窗/--seconds 自动关)都收掉串口线程 + self.dlpc.close() + except Exception: + pass + super().destroy() + def _show_edid_fields(self): """逐字节字段解析弹窗(当前所选显示器), 自动换行文本视图""" from tkinter import messagebox @@ -1384,8 +1586,50 @@ def run_dump(edid_fields=False): return 0 +def run_dlpc(port, baud, cmds): + """RS485 直连模式: 逐条发送光机命令, 等响应打印译码结果(自动化脚本用)。 + + 返回码: 任一命令非法/超时/响应非 0xF0 成功 -> 1, 全部成功 -> 0。 + """ + if not dlpc_rs485.HAS_SERIAL: + print('错误: 未安装 pyserial, 请先执行 py -m pip install pyserial') + return 2 + try: + ser = dlpc_rs485.serial.Serial(port, baud, timeout=0.1) + except Exception as e: + print(f'打开串口失败: {port} @ {baud}: {e}') + return 1 + print(f'已连接 {port} @ {baud} (RS485 半双工, 单发单收)') + parser = dlpc_rs485.FrameParser() + rc = 0 + for cmd in cmds: + try: + frame = dlpc_rs485.build_frame(cmd) + except ValueError as e: + print(f'→ {cmd} 命令非法: {e}') + rc = 1 + continue + ser.write(frame) + print(f'→ {cmd} [{frame.hex(" ").upper()}]') + deadline = time.time() + 35.0 # M737 定时投光中延迟应答(定时上限 32.7s) + got = None + while got is None and time.time() < deadline: + data = ser.read(256) + if data: + got = next(iter(parser.feed(data)), None) + if got is None: + print('← (35s 超时无响应)') + rc = 1 + else: + print(f'← {dlpc_rs485.describe(got)} [{got.raw.hex(" ").upper()}]') + if got.type_code != 0xF0: + rc = 1 + ser.close() + return rc + + def main(): - ap = argparse.ArgumentParser(description='HDMI EDID 查看器 + 测试图案') + ap = argparse.ArgumentParser(description='HDMI EDID 查看器 + 测试图案 + 光机控制') ap.add_argument('--dump', action='store_true', help='控制台输出 EDID 解算结果, 不开 GUI') ap.add_argument('--edid-fields', action='store_true', help='控制台输出 EDID 逐字节字段解析表(配合 --dump 风格, 不开 GUI); exe 版不支持') @@ -1396,6 +1640,12 @@ def main(): help='测试卡区域: WxH 黑底居中 或 full 铺满所选显示器(默认 640x360)') ap.add_argument('--monitor', type=int, default=1, help='--test-pattern 用的显示器序号(默认 1)') ap.add_argument('--seconds', type=int, default=0, help='自动退出秒数(GUI/图案冒烟测试)') + ap.add_argument('--port', default=None, help='光机 RS485 串口(如 COM9), 配合 --dlpc-cmd') + ap.add_argument('--baud', type=int, default=dlpc_rs485.DEFAULT_BAUD, + help='光机串口波特率(默认 115200)') + ap.add_argument('--dlpc-cmd', action='append', default=None, dest='dlpc_cmds', + metavar='CMD', + help='RS485 发送光机命令(可多次), 如 --dlpc-cmd M730S0 --dlpc-cmd M999') args = ap.parse_args() # LT8619C 板卡热插拔后可能停留在"仅电脑屏幕"拓扑, 先强制扩展, @@ -1413,6 +1663,11 @@ def main(): 'python hdmi_gui.py --edid-fields') sys.exit(1) sys.exit(run_dump(edid_fields=args.edid_fields)) + if args.dlpc_cmds: + if not args.port: + print('错误: --dlpc-cmd 需要配合 --port 指定串口(如 --port COM9)') + sys.exit(2) + sys.exit(run_dlpc(args.port, args.baud, args.dlpc_cmds)) if args.test_pattern: displays = get_displays() if not displays: diff --git a/HDMI-Tool/src/selftest_rs485.py b/HDMI-Tool/src/selftest_rs485.py new file mode 100644 index 0000000..8c3d75f --- /dev/null +++ b/HDMI-Tool/src/selftest_rs485.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""dlpc_rs485 协议自检(无硬件): 对照上游 protocols.md 的实测帧向量做回归 + +用法: python selftest_rs485.py (全部通过退出码 0) +""" +import sys + +import dlpc_rs485 as d + +fails = [] + + +def check(name, got, want): + ok = got == want + print(('PASS' if ok else 'FAIL'), name, '->', got, '' if ok else f'(期望 {want})') + if not ok: + fails.append(name) + + +# 1) 帧构造: protocols.md 实测示例 M730S0T2000 +f = d.build_frame('M730S0T2000') +check('build_frame(M730S0T2000)', f.hex(' ').upper(), + 'D5 01 0B 4D 37 33 30 53 30 54 32 30 30 30 8C') + +# 2) 增量解析: ok 应答跨两个半包喂入 +p = d.FrameParser() +r1 = p.feed(bytes.fromhex('B5 F0')) +r2 = p.feed(bytes.fromhex('02 6F 6B CC')) +check('split-feed frames', len(r1) + len(r2), 1) +fr = (r1 + r2)[0] +check('type_code', fr.type_code, 0xF0) +check('payload', fr.payload, 'ok') +check('describe(ok)', d.describe(fr), 'ok') + +# 3) 前导噪声 + 0xFD err:OL 译码(字符串标记, 不附 DLPC 释义) +body = bytes.fromhex('FD 06 65 72 72 3A 4F 4C') # "err:OL" +good = bytes([0xB5]) + body + bytes([d.sum_crc(body)]) +frames = d.FrameParser().feed(b'\x01\x02' + good) +check('noise+err-frame count', len(frames), 1) +check('describe(err:OL)', d.describe(frames[0]), '参数越界/读回复核不一致 err:OL') + +# 4) 0xFA + 数字错误码 err:3 追加 DLPC 释义 +body = bytes.fromhex('FA 05') + b'err:3' +fa = bytes([0xB5]) + body + bytes([d.sum_crc(body)]) +frames = d.FrameParser().feed(fa) +check('describe(err:3)', d.describe(frames[0]), + '开光机通信失败 err:3(I2C NACK(器件无应答))') + +# 5) CRC 坏帧被丢弃后继续同步到下一帧 +bad = bytearray(good) +bad[-1] ^= 0xFF +frames = d.FrameParser().feed(bytes(bad) + good) +check('bad-crc then good', len(frames), 1) +check('recovered type', frames[0].type_code, 0xFD) + +# 6) 非法输入拒绝 +for bad_cmd in ('', ' '): + try: + d.build_frame(bad_cmd) + fails.append('empty-cmd-not-rejected') + except ValueError: + pass +try: + d.build_frame('M' * 40) + fails.append('long-cmd-not-rejected') +except ValueError: + pass +print('空/超长命令拒绝: PASS') + +# 7) 预设命令全部可组帧 +for _name, cmd, _note in d.PRESETS: + d.build_frame(cmd) +print(f'预设 {len(d.PRESETS)} 条全部可组帧: PASS') + +print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS') +sys.exit(1 if fails else 0) diff --git a/README.md b/README.md index 7d5b799..d3f7693 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套上位机工 | 工具 | 用途 | 获取 | |---|---|---| -| [HDMI-Tool](HDMI-Tool/) | HDMI 源端:EDID 查看/逐字节解析、分辨率切换、纯色与测试图案全屏投屏 | 到 [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) | +| [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) | | [I2C-Inject](I2C-Inject/) | LT8619C 寄存器在线读写:RP2040 注入器在 demo 板 I2C 总线上择机注入(避让主控轮询,写后自动读回校验) | 需配合 RP2040 固件 `rp2040_inject.py`;详见 [I2C-Inject/README.md](I2C-Inject/README.md) | ## 环境要求 @@ -49,4 +49,4 @@ python publish_gitea.py HDMI-Tool-v<版本>-win64.zip --insecure # 上传 Gite - 源码与打包产物(exe、pattern_slots.json)均入库;发布 zip 不入库,只上传 Gitea Release - 两件工具的寄存器语义(0x606E 位交换 / 0x606D 整组顺序)均经 demo 板逻辑分析仪 双色交叉实测,过程数据与规程存于上游主仓库 `doc/architecture/` -- 更新:2026-09-10 +- 更新:2026-09-11