diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f6ff9ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Python +__pycache__/ +*.pyc + +# PyInstaller 工作目录(打包产物 exe 已直接入库, 构建中间物不入库) +build/ +dist/ +*.spec diff --git a/HDMI-Tool/README.md b/HDMI-Tool/README.md new file mode 100644 index 0000000..f0d4f0f --- /dev/null +++ b/HDMI-Tool/README.md @@ -0,0 +1,76 @@ +# HDMI-Tool — HDMI 源端测试工具(LT8619C/DLPC3421 转接板调试) + +> 更新:2026-09-03。单文件 Python GUI(`hdmi_gui.py`),另附命令行分辨率/图案工具 +> `hdmi_source.bat`。已打包版直接双击 `HDMI-Tool.exe`,无需安装 Python。 + +--- + +## 1. 功能总览 + +| 功能 | 说明 | +|---|---| +| EDID 查看 | 枚举各显示输出口,从注册表读取 EDID 原始数据,按 EDID 1.4/CEA-861 解算:厂商/型号/尺寸/支持模式(DTD/已建立/标准/CEA-VIC)/首选时序完整参数/校验和 | +| 分辨率切换 | 第二行"分辨率"下拉列出显卡按该屏 EDID 提供的全部模式;**应用**=临时切换(不写注册表,重启还原),**恢复**=回到本次会话切改前的模式 | +| 测试图案 | 全屏投到所选显示器,测试卡区域可选 `640x360 / 800x600 / full 铺满`;内置 纯红/纯绿/纯蓝/棋盘格/网格线 + R/G/B 三行纯色值按钮(55/AA/FF,与 dist 下 solid_*.png 对应)+ 3 个自定义图片栏位 | +| 命令行模式 | 自动化测试脚本可直接调用(见 §4) | + +## 2. 快速上手(GUI) + +1. 双击 `HDMI-Tool.exe`(或 `python hdmi_gui.py`,需 Python ≥3.10 + pyserial 不需要,纯标准库;自定义图片 JPG/BMP 需 Pillow) +2. 顶部显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举 +3. **切分辨率**:第二行选模式 → 点"应用";测完点"恢复" +4. **投测试图**: + - 先在图案按钮行右侧选**测试卡区域**(640x360 / 800x600 / full) + - 点图案按钮即全屏投放;按 **ESC** 退出 + - R/G/B 三行纯色按钮(55/AA/FF)用于位交换类测试:55 与 AA 互为字节镜像对,FF 为全高基准 + - 自定义图片:点"⚙"配置名称与图片文件(图片会复制到本目录),点栏位名投放 +5. 区域与自定义栏位配置自动保存在本目录 `pattern_slots.json` + +## 3. 典型场景(LT8619C demo 板测试) + +``` +1. HDMI 接 demo 板 → 启动 HDMI-Tool → 刷新 → 选中 demo 板显示器 +2. 分辨率切 800x600@60(demo 板 shadow EDID 首选)→ 测试卡区域选 800x600 +3. 点 R/G/B 行的纯色按钮投图 → 配合逻辑分析仪/万用表测 RGB 输出 +4. 测完"恢复"分辨率 +``` + +## 4. 命令行模式 + +``` +python hdmi_gui.py --dump 控制台输出 EDID 解算(exe 版不支持) +python hdmi_gui.py --test-pattern Blue --area 800x600 --seconds 5 + 出图 5 秒自动退出 +python hdmi_gui.py --test-pattern Custom1 投自定义栏位 1 + --area {640x360,800x600,full} --monitor N --seconds S +``` + +**hdmi_source.bat**(PowerShell 源端工具,功能与 GUI 互补): + +``` +tools\hdmi_source.bat -List 枚举显示器与支持分辨率(验证 EDID) +tools\hdmi_source.bat -Monitor 1 -Mode 800x600 切模式 +tools\hdmi_source.bat -Mode 800x600 -Pattern -Restore + 切模式→出图案→退出后自动恢复 +``` + +## 5. 目录内容 + +| 文件 | 用途 | +|---|---| +| `HDMI-Tool.exe` | 打包好的单文件版(--noconsole;`--dump` 请用源码方式跑) | +| `hdmi_gui.py` | 主程序源码 | +| `build_exe.py` | 打包脚本(`python build_exe.py`,自动装 PyInstaller;`--debug` 额外出带控制台版) | +| `hdmi_source.ps1 / .bat` | 源端分辨率/图案命令行工具 | +| `gen_bw_patterns.py` / `gen_solid_colors.py` | 测试图生成脚本(bw 黑白系列 640x360 / solid 纯色系列 800x600) | +| `solid_*.png` | 9 张 800x600 纯色图(R/G/B 各 55/AA/FF,供自定义栏位或脚本使用) | +| `bw_*.png` | 5 张 640x360 黑白测试图(西门子星/频率条纹/渐变棋盘/波带/综合卡) | +| `logos/` | 图标源文件与 `make_ico.py`(改 logo 后重跑再打包) | +| `pattern_slots.json` | 自定义栏位 + 测试卡区域配置(运行后自动生成/更新) | + +## 6. 注意事项 + +- 测试卡区域与自定义栏位配置按 exe/脚本所在目录各自保存,两份安装互不影响 +- 重打包后 Windows 图标缓存可能仍显示旧图标:`ie4uinit -show` 或改名即可 +- 多屏环境切分辨率前先确认显示器序号(下拉框中名称与 `-List` 输出一致) +- 修改源码后重新打包前,先结束残留的 HDMI-Tool 进程(build_exe.py 会自动处理) diff --git a/HDMI-Tool/build_exe.py b/HDMI-Tool/build_exe.py new file mode 100644 index 0000000..b729cbf --- /dev/null +++ b/HDMI-Tool/build_exe.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +把 hdmi_gui.py 打包成单文件 exe(工具名: HDMI-Tool) +================================ +使用 PyInstaller --onefile --noconsole: Python 解释器 + 标准库 + tkinter +全部打进一个 exe, 目标机器无需安装任何依赖。 + +用法: + python build_exe.py 自动安装 PyInstaller(如缺)并打包 + python build_exe.py --debug 额外打一个带控制台的变体(--dump 可用) + +产物: tools/dist/HDMI-Tool.exe(及 --debug 时的 HDMI-Tool-console.exe) +首次运行 onefile exe 会先解压到临时目录, 启动比脚本慢一两秒, 属正常现象。 +""" + +import os +import subprocess +import sys + +BASE = os.path.dirname(os.path.abspath(__file__)) +ICO = os.path.join(BASE, 'logos', 'logo_ph_orange.ico') + + +def ensure_pyinstaller(): + try: + import PyInstaller # noqa: F401 + return + except ImportError: + pass + print('PyInstaller 未安装, 正在安装...') + subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'pyinstaller']) + + +def remove_stale(exe_path): + """删除旧 exe; 若被占用(上次验证的进程还在), 先结束同名进程再重试""" + if not os.path.exists(exe_path): + return + try: + os.remove(exe_path) + return + except PermissionError: + pass + import time + print(f'{exe_path} 被占用, 结束残留进程后重试...') + subprocess.run(['taskkill', '/F', '/IM', os.path.basename(exe_path)], + capture_output=True) + time.sleep(1.0) + os.remove(exe_path) + + +def build(name, extra_args): + dist = os.path.join(BASE, 'dist') + work = os.path.join(BASE, 'build') + remove_stale(os.path.join(dist, name + '.exe')) + cmd = [sys.executable, '-m', 'PyInstaller', + '--onefile', '--clean', '--name', name, + '--distpath', dist, '--workpath', work, '--specpath', work, + ] + extra_args + [os.path.join(BASE, '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('\n>>', ' '.join(cmd)) + subprocess.check_call(cmd) + out = os.path.join(dist, name + '.exe') + print(f'完成: {out} ({os.path.getsize(out) / 1024 / 1024:.1f} MB)') + + +def main(): + build_console = '--debug' in sys.argv + ensure_pyinstaller() + # 常规版: 双击即用, 无黑框(GUI/图案/自定义栏位全部可用, --dump 除外) + build('HDMI-Tool', ['--noconsole']) + if build_console: + # 调试版: 带控制台, --dump / 异常栈可见 + build('HDMI-Tool-console', []) + + +if __name__ == '__main__': + main() diff --git a/HDMI-Tool/bw_checker_sweep.png b/HDMI-Tool/bw_checker_sweep.png new file mode 100644 index 0000000..24fcfe3 Binary files /dev/null and b/HDMI-Tool/bw_checker_sweep.png differ diff --git a/HDMI-Tool/bw_star.png b/HDMI-Tool/bw_star.png new file mode 100644 index 0000000..de2e9d8 Binary files /dev/null and b/HDMI-Tool/bw_star.png differ diff --git a/HDMI-Tool/bw_sweep.png b/HDMI-Tool/bw_sweep.png new file mode 100644 index 0000000..6e9a59a Binary files /dev/null and b/HDMI-Tool/bw_sweep.png differ diff --git a/HDMI-Tool/bw_testcard.png b/HDMI-Tool/bw_testcard.png new file mode 100644 index 0000000..9dd5021 Binary files /dev/null and b/HDMI-Tool/bw_testcard.png differ diff --git a/HDMI-Tool/bw_zoneplate.png b/HDMI-Tool/bw_zoneplate.png new file mode 100644 index 0000000..6de9910 Binary files /dev/null and b/HDMI-Tool/bw_zoneplate.png differ diff --git a/HDMI-Tool/gen_bw_patterns.py b/HDMI-Tool/gen_bw_patterns.py new file mode 100644 index 0000000..0669689 --- /dev/null +++ b/HDMI-Tool/gen_bw_patterns.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +生成 640x360 黑白测试图, 供 HDMI 源端工具的自定义栏位使用 +================================================================ +输出到 exe 目录(tools/dist/), 并把前 3 张写入 pattern_slots.json, +启动 hdmi_gui 后自定义栏位即可直接点用; 其余两张可用 "⚙" 换上。 + +图案清单(均为 640x360 黑白, 1:1 投屏无缩放): + bw_star.png 西门子星: 60 楔放射星, 看极限分辨率与中心偏移 + bw_sweep.png 频率扫描条纹: 周期 24px 渐减到 3px(上竖线/下横线) + bw_checker_sweep.png 渐变棋盘: 格子尺寸 2px 递增到 32px 共 9 段 + bw_zoneplate.png 同心波带片: 环距向中心递减, 看各向混叠 + bw_testcard.png 综合测试卡: 灰阶/刻度/细棋盘/粗细线/标记 + +用法: python gen_bw_patterns.py +""" + +import json +import math +import os + +from PIL import Image, ImageDraw + +W, H = 640, 360 +BASE = os.path.dirname(os.path.abspath(__file__)) +OUT = os.path.join(BASE, 'dist') + + +def save(img, name): + os.makedirs(OUT, exist_ok=True) + path = os.path.join(OUT, name) + img.save(path) + print(f'写出 {path}') + + +def gen_star(): + """西门子星: 60 个黑白交替楔形, 边缘楔宽约 17px, 向中心无限加密""" + img = Image.new('L', (W, H), 255) + d = ImageDraw.Draw(img) + cx, cy, R = W // 2, H // 2, 165 + n = 60 + for i in range(0, n, 2): # 黑楔在偶数位, 底为白 + d.pieslice([cx - R, cy - R, cx + R, cy + R], + i * 360.0 / n, (i + 1) * 360.0 / n, fill=0) + d.ellipse([cx - R - 6, cy - R - 6, cx + R + 6, cy + R + 6], + outline=0, width=3) + d.rectangle([0, 0, W - 1, H - 1], outline=0, width=2) + save(img, 'bw_star.png') + + +def gen_sweep(): + """频率扫描条纹: 左到右黑白条纹周期 24px 渐减到 3px; 上半竖线, 下半横线""" + img = Image.new('L', (W, H), 255) + px = img.load() + # 上半: 竖条纹 + ph = 0.0 + for x in range(W): + t = x / (W - 1) + ph += 1.0 / (24 - 21 * t) + if (ph % 1.0) < 0.5: + for y in range(H // 2): + px[x, y] = 0 + # 下半: 横条纹 + ph = 0.0 + for y in range(H // 2, H): + t = (y - H // 2) / (H // 2 - 1) + ph += 1.0 / (24 - 21 * t) + if (ph % 1.0) < 0.5: + for x in range(W): + px[x, y] = 0 + d = ImageDraw.Draw(img) + d.line([0, H // 2, W, H // 2], fill=0, width=2) + d.rectangle([0, 0, W - 1, H - 1], outline=0, width=2) + d.text((8, 6), 'H sweep 24px->3px', fill=0) + d.text((8, H // 2 + 6), 'V sweep 24px->3px', fill=0) + save(img, 'bw_sweep.png') + + +def gen_checker_sweep(): + """渐变棋盘: 格子尺寸从 2px 逐段加倍到 32px, 看多大格子开始可分辨""" + img = Image.new('L', (W, H), 0) + d = ImageDraw.Draw(img) + sizes = [2, 3, 4, 6, 8, 12, 16, 24, 32] + seg = W / len(sizes) + for i, s in enumerate(sizes): + x0, x1 = int(round(i * seg)), int(round((i + 1) * seg)) + for yy in range(0, H, s): + for xx in range(x0, x1, s): + if ((xx // s) + (yy // s)) % 2 == 0: + d.rectangle([xx, yy, min(xx + s, x1) - 1, min(yy + s, H) - 1], + fill=255) + d.line([x1, 0, x1, H], fill=255, width=2) + d.text((4, 4), 'checker 2..32px', fill=255) + save(img, 'bw_checker_sweep.png') + + +def gen_zoneplate(): + """同心波带片: 环距中心处约 31px、边缘约 3.5px, 观察各方向混叠""" + img = Image.new('L', (W, H), 255) + px = img.load() + cx, cy, k = W / 2, H / 2, 0.0008 + for y in range(H): + dy = y - cy + for x in range(W): + dx = x - cx + if math.cos(k * (dx * dx + dy * dy)) > 0: + px[x, y] = 0 + d = ImageDraw.Draw(img) + d.rectangle([0, 0, W - 1, H - 1], outline=0, width=2) + save(img, 'bw_zoneplate.png') + + +def gen_testcard(): + """综合测试卡: 灰阶条 + 刻度 + 细棋盘 + 粗细线 + 角标""" + img = Image.new('L', (W, H), 0) + d = ImageDraw.Draw(img) + + # 外框与 40px 刻度(边缘短刻线) + d.rectangle([0, 0, W - 1, H - 1], outline=255, width=2) + for x in range(40, W, 40): + d.line([x, 0, x, 10], fill=255, width=1) + d.line([x, H, x, H - 10], fill=255, width=1) + for y in range(40, H, 40): + d.line([0, y, 10, y], fill=255, width=1) + d.line([W, y, W - 10, y], fill=255, width=1) + + # 顶部 16 级灰阶条 + gw = W // 16 + for i in range(16): + v = round(i * 255 / 15) + d.rectangle([i * gw, 20, (i + 1) * gw - 1, 44], fill=v, outline=255) + + # 左下: 1px 棋盘块 (640/4=160 宽 x 72 高) + bx, by, bs = 16, H - 96, 2 + for yy in range(by, by + 72, bs): + for xx in range(bx, bx + 160, bs): + if ((xx // bs) + (yy // bs)) % 2 == 0: + d.rectangle([xx, yy, xx + bs - 1, yy + bs - 1], fill=255) + d.text((bx, by - 14), '2px checker', fill=255) + + # 右下: 1/2/4px 白线组(横线), 看线条再现 + lx = W - 210 + for row, lw in enumerate((1, 2, 4)): + y0 = H - 100 + row * 26 + for off in range(0, 13, lw * 2): + d.line([lx, y0 + off, lx + 190, y0 + off], fill=255, width=lw) + d.text((lx, H - 114), '1/2/4px lines', fill=255) + + # 中心十字(1px) + cx, cy = W // 2, H // 2 + d.line([cx, 60, cx, H - 60], fill=255, width=1) + d.line([W // 4, cy, W - W // 4, cy], fill=255, width=1) + + # 四角 L 标记 + m, L = 16, 28 + for sx, sy in ((m, m), (W - 1 - m, m), (m, H - 1 - m), (W - 1 - m, H - 1 - m)): + dx = -1 if sx > cx else 1 + dy = -1 if sy > cy else 1 + d.line([sx, sy, sx + dx * L, sy], fill=255, width=2) + d.line([sx, sy, sx, sy + dy * L], fill=255, width=2) + + d.text((W // 2 - 60, 52), '640x360 TEST', fill=255) + save(img, 'bw_testcard.png') + + +def write_slots(): + slots = [ + {'name': '西门子星', 'file': 'bw_star.png'}, + {'name': '频率条纹', 'file': 'bw_sweep.png'}, + {'name': '渐变棋盘', 'file': 'bw_checker_sweep.png'}, + ] + path = os.path.join(OUT, 'pattern_slots.json') + with open(path, 'w', encoding='utf-8') as f: + json.dump(slots, f, ensure_ascii=False, indent=2) + print(f'写出 {path}(前 3 张已配入自定义栏位, 其余两张可用 "⚙" 换上)') + + +if __name__ == '__main__': + gen_star() + gen_sweep() + gen_checker_sweep() + gen_zoneplate() + gen_testcard() + write_slots() diff --git a/HDMI-Tool/gen_solid_colors.py b/HDMI-Tool/gen_solid_colors.py new file mode 100644 index 0000000..b6cc4d8 --- /dev/null +++ b/HDMI-Tool/gen_solid_colors.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +生成 800x600 纯色测试图, 供 RGB 位交换(0x606E)验证使用 +================================================================ +配合 demo 板当前 EDID 首选时序 800x600@60, 全屏纯色输出。 +用法: python gen_solid_colors.py → 输出到 tools/dist/solid_*.png +在 HDMI-Tool 中通过自定义栏位(⚙ 换图)加载。 +""" + +import os + +from PIL import Image + +W, H = 800, 600 # 与 demo 板 EDID 首选时序一致, 全屏铺满 +BASE = os.path.dirname(os.path.abspath(__file__)) +OUT = os.path.join(BASE, 'dist') + +# (文件名后缀, RGB 值, 用途) +COLORS = [ + ('FF0000', (0xFF, 0x00, 0x00), '通道映射: R=D[23:16]'), + ('00FF00', (0x00, 0xFF, 0x00), '通道映射: G=D[15:8]'), + ('0000FF', (0x00, 0x00, 0xFF), '通道映射: B=D[7:0]'), + ('AA0000', (0xAA, 0x00, 0x00), 'R 交换检测: 0xAA(10101010)'), + ('550000', (0x55, 0x00, 0x00), 'R 交换检测: 0x55(01010101)'), + ('00AA00', (0x00, 0xAA, 0x00), 'G 交换检测'), + ('005500', (0x00, 0x55, 0x00), 'G 交换检测'), + ('0000AA', (0x00, 0x00, 0xAA), 'B 交换检测'), + ('000055', (0x00, 0x00, 0x55), 'B 交换检测'), +] + + +def main(): + os.makedirs(OUT, exist_ok=True) + for suffix, rgb, note in COLORS: + img = Image.new('RGB', (W, H), rgb) + path = os.path.join(OUT, f'solid_{suffix}.png') + img.save(path) + print(f'写出 {path} # {note}') + print(f'\n共 {len(COLORS)} 张, 分辨率 {W}x{H}(匹配 demo 板 800x600 EDID)。') + + +if __name__ == '__main__': + main() diff --git a/HDMI-Tool/hdmi_gui.py b/HDMI-Tool/hdmi_gui.py new file mode 100644 index 0000000..ad6cd12 --- /dev/null +++ b/HDMI-Tool/hdmi_gui.py @@ -0,0 +1,1126 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +HDMI EDID 查看器 + 测试图案投屏工具 +================================================ +配合 LT8619C(HDMI 转 RGB888)→ DLPC3421 转接板调试使用。 + +功能: + 1. 枚举各显示输出口接的设备, 从注册表读取其 EDID 原始数据, + 按 EDID 1.4 / CEA-861 标准解算: 厂商/产品/名称/尺寸/Gamma/输入类型、 + 支持的全部模式(详细时序 DTD / 已建立时序 / 标准时序 / CEA VIC)、 + 首选时序完整参数(像素时钟/消隐/同步极性)、CEA 扩展能力、校验和。 + 2. 向所选屏幕全屏投测试图案(黑底 1:1 居中, 测试卡区域可选 + 640x360 / 800x600 / full 铺满): 纯红 / 纯绿 / 纯蓝 / 黑白棋盘格 / + 网格线 + 3 个自定义图片栏位, 点击或 ESC 退出。 + +仅用标准库(ctypes / winreg / tkinter), 无需 pip 安装任何依赖。 + +命令行模式(自动化测试用): + python hdmi_gui.py 启动 GUI + python hdmi_gui.py --dump 控制台输出 EDID 解算结果 + python hdmi_gui.py --test-pattern Checker --seconds 3 + 直接出图案, 3 秒自动退出 + python hdmi_gui.py --test-pattern Blue --area 800x600 + 指定测试卡区域(full=铺满所选屏) + python hdmi_gui.py --seconds 5 GUI 5 秒自动关闭(冒烟测试) +""" + +import argparse +import ctypes +import ctypes.wintypes as wt +import json +import math +import os +import shutil +import struct +import sys +import winreg + +# 进程级 DPI 感知: 保证图案窗口按物理像素精确定位/绘制 +try: + ctypes.windll.shcore.SetProcessDpiAwareness(2) +except Exception: + pass + +# 可选 Pillow: 装了则支持 JPEG/BMP/WebP, 没装则用 tkinter 原生(仅 PNG/GIF) +try: + from PIL import Image, ImageTk + HAS_PIL = True +except ImportError: + HAS_PIL = False + +# ============================================================ +# 自定义图案栏位: 配置存于脚本目录 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 = 3 +SLOT_CONFIG = os.path.join(BASE_DIR, 'pattern_slots.json') +IMG_EXTS = ['.png', '.gif'] + (['.jpg', '.jpeg', '.bmp', '.webp'] if HAS_PIL else []) + + +def load_slots(): + """读 pattern_slots.json, 返回 (slots, area)。 + + 新格式为 {'area': 'WxH'|'full', 'slots': [...]}; 兼容旧版纯数组格式(无 area)。 + """ + 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 [] + 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}, 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 + + +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 get_icon_path(): + """工具图标: 源码模式取 logos/ 下, 打包模式取 exe 内嵌资源(_MEIPASS)""" + if getattr(sys, 'frozen', False): + p = os.path.join(getattr(sys, '_MEIPASS', ''), 'logo_ph_orange.ico') + else: + p = os.path.join(BASE_DIR, 'logos', 'logo_ph_orange.ico') + return p if os.path.exists(p) else None + + +# ============================================================ +# 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\\\\' → 注册表 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 + + +# ============================================================ +# 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('> 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('> 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] & 0x08: feats.append('sRGB 特性') + if raw[24] & 0x04: 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] + hx = ' '.join(f'{v:02X}' for v in chunk) + asc = ''.join(chr(v) if 32 <= v < 127 else '.' for v in chunk) + out.append(f'{off:04X}: {hx:<48} {asc}') + return '\n'.join(out) + + +# ============================================================ +# 测试图案窗口(tkinter) — 测试卡区域可选 640x360/800x600/全屏 +# ============================================================ +PAT_W, PAT_H = 640, 360 +AREA_OPTIONS = ('640x360', '800x600', 'full') +DEFAULT_AREA = AREA_OPTIONS[0] +PATTERN_NAMES = {'Red': '纯红', 'Green': '纯绿', 'Blue': '纯蓝', + 'Checker': '黑白棋盘格', 'Grid': '网格线'} +# 纯色测试值(位交换测试用, 与 tools/dist/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 _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('', 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('', 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('', draw) + top.after(50, draw) + top.wait_window(top) + + +# ============================================================ +# 主 GUI +# ============================================================ +def run_gui(auto_close=0): + import tkinter as tk + from tkinter import ttk + + # 让任务栏图标独立显示(不归并到 Python/通用组), 须在创建窗口前调用 + try: + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID('hulk.HDMI-Tool') + except Exception: + pass + + class App(tk.Tk): + def __init__(self): + super().__init__() + self.title('HDMI-Tool — EDID 查看器 + 测试图案 (LT8619C 转接板调试)') + self.geometry('960x880') + ic = get_icon_path() + if ic: + try: + self.iconbitmap(ic) # 窗口标题栏图标 + except Exception: + pass + self.displays = [] + self.slots, self.area = load_slots() + self._modes = {} # 'WxH@Hz' -> DEVMODEW(当前所选屏) + self._orig_modes = {} # device -> 切改前的原始 DEVMODEW + self._build() + + def _build(self): + # --- 顶部: 显示器选择 --- + top = ttk.Frame(self, padding=(8, 6)) + top.pack(fill='x') + ttk.Label(top, text='显示器:').pack(side='left') + self.cbo = ttk.Combobox(top, state='readonly', width=62) + self.cbo.pack(side='left', padx=6) + self.cbo.bind('<>', self._on_select) + ttk.Button(top, text='刷新', command=self.load_displays, width=8).pack(side='left') + self.lbl_cur = ttk.Label(top, text='') + self.lbl_cur.pack(side='left', padx=12) + + # --- 顶部第二行: 分辨率切换 --- + top2 = ttk.Frame(self, padding=(8, 2)) + top2.pack(fill='x') + ttk.Label(top2, text='分辨率:').pack(side='left') + self.cbo_mode = ttk.Combobox(top2, state='readonly', width=16) + self.cbo_mode.pack(side='left', padx=6) + ttk.Button(top2, text='应用', width=8, command=self._apply_mode).pack(side='left') + ttk.Button(top2, text='恢复', width=8, command=self._restore_mode).pack(side='left', padx=6) + ttk.Label(top2, text='列表为显卡按该屏 EDID 提供的模式; 切换临时生效, 重启还原', + foreground='#666666').pack(side='left', padx=10) + + # --- 中部: 模式列表 + EDID 信息 --- + mid = ttk.Frame(self, padding=(8, 0)) + mid.pack(fill='both', expand=True) + + left = ttk.LabelFrame(mid, text='支持的模式 (EDID 解算)', padding=4) + left.pack(side='left', fill='both', expand=True) + self.tv_modes = ttk.Treeview(left, columns=('mode', 'hz', 'clk', 'src'), + show='headings', height=12) + for col, txt, w, anchor in (('mode', '模式', 110, 'w'), ('hz', '刷新率', 65, 'e'), + ('clk', '像素时钟', 80, 'e'), ('src', '来源', 90, 'w')): + self.tv_modes.heading(col, text=txt) + self.tv_modes.column(col, width=w, anchor=anchor) + sb1 = ttk.Scrollbar(left, orient='vertical', command=self.tv_modes.yview) + self.tv_modes.configure(yscrollcommand=sb1.set) + self.tv_modes.pack(side='left', fill='both', expand=True) + sb1.pack(side='right', fill='y') + + right = ttk.LabelFrame(mid, text='EDID 信息', padding=4) + right.pack(side='left', fill='both', expand=True, padx=(6, 0)) + self.tv_info = ttk.Treeview(right, columns=('k', 'v'), show='headings', height=12) + self.tv_info.heading('k', text='项目') + self.tv_info.heading('v', text='值') + self.tv_info.column('k', width=150, anchor='w') + self.tv_info.column('v', width=420, anchor='w') + sb2 = ttk.Scrollbar(right, orient='vertical', command=self.tv_info.yview) + self.tv_info.configure(yscrollcommand=sb2.set) + self.tv_info.pack(side='left', fill='both', expand=True) + sb2.pack(side='right', fill='y') + + # --- 测试图案按钮组 --- + grp = ttk.LabelFrame(self, text='测试图案 — 全屏投至所选显示器, 黑底 1:1 居中, 按 ESC 退出', + padding=(10, 6)) + grp.pack(fill='x', padx=8, pady=(6, 0)) + row1 = ttk.Frame(grp) + row1.pack(fill='x') + for key in ('Red', 'Green', 'Blue', 'Checker', 'Grid'): + ttk.Button(row1, text=PATTERN_NAMES[key], width=12, + command=lambda k=key: self._show_pattern(k)).pack(side='left', padx=5) + # 纯色测试值三行(R/G/B 按通道分组, 位交换测试用) + for ch, items in SOLID_ROWS: + rowf = ttk.Frame(grp) + rowf.pack(fill='x', pady=(3, 0)) + ttk.Label(rowf, text=f'{ch}: 0x', width=4).pack(side='left') + for label, color in items: + ttk.Button(rowf, text=label, width=5, + command=lambda c=color: self._show_solid(c) + ).pack(side='left', padx=3) + ttk.Label(row1, text='测试卡区域:').pack(side='left', padx=(16, 2)) + self.area_var = tk.StringVar(value=self.area) + cbo_area = ttk.Combobox(row1, textvariable=self.area_var, width=8, + state='readonly', values=list(AREA_OPTIONS)) + cbo_area.pack(side='left') + cbo_area.bind('<>', lambda _e: self._save_area()) + row2 = ttk.Frame(grp) + row2.pack(fill='x', pady=(4, 0)) + self.slot_buttons = [] + for i in range(SLOT_COUNT): + cell = ttk.Frame(row2) + cell.pack(side='left', padx=(5, 0)) + btn = ttk.Button(cell, text=self.slots[i]['name'], width=12, + command=lambda i=i: self._show_custom(i)) + btn.pack(side='left') + ttk.Button(cell, text='⚙', width=3, + command=lambda i=i: self._configure_slot(i)).pack(side='left', padx=(4, 0)) + self.slot_buttons.append(btn) + ttk.Label(row2, text='自定义: 点击投图, ⚙ 配置名称与图片(图片保存在脚本目录)', + foreground='#666666').pack(side='left', padx=10) + + # --- 底部: EDID hex dump --- + bot = ttk.LabelFrame(self, text='EDID 原始数据', padding=4) + bot.pack(fill='both', expand=False, padx=8, pady=(6, 8)) + self.txt_hex = tk.Text(bot, height=8, font=('Consolas', 9), + state='disabled', wrap='none', background='#101010', + foreground='#C8C8C8') + sb3 = ttk.Scrollbar(bot, orient='vertical', command=self.txt_hex.yview) + self.txt_hex.configure(yscrollcommand=sb3.set) + self.txt_hex.pack(side='left', fill='both', expand=True) + sb3.pack(side='right', fill='y') + + def load_displays(self): + keep = self.cbo.current() + self.displays = get_displays() + self.cbo['values'] = [f"{d['device']} {d['adapter']} [{d['mon_name']}]" for d in self.displays] + if self.displays: + self.cbo.current(max(0, min(keep, len(self.displays) - 1))) + self._on_select() + else: + self.lbl_cur.config(text='未找到活动显示器') + + def _on_select(self, _evt=None): + idx = self.cbo.current() + if idx < 0 or idx >= len(self.displays): + return + d = self.displays[idx] + self.lbl_cur.config(text=f"当前输出: {d['cur_mode']}") + # 分辨率下拉: 该屏可用模式, 默认选中当前模式 + keys, modes = enum_modes(d['device']) + self._modes = modes + self.cbo_mode['values'] = keys + if d['cur_mode'] in modes: + self.cbo_mode.set(d['cur_mode']) + elif keys: + self.cbo_mode.set(keys[0]) + for tv in (self.tv_modes, self.tv_info): + tv.delete(*tv.get_children()) + if not d['edid']: + self.tv_info.insert('', 'end', values=('EDID', '读取失败(注册表无 EDID 数据)')) + return + e = parse_edid(d['edid']) + for k, v in edid_info_lines(e): + self.tv_info.insert('', 'end', values=(k, v)) + for k, t in enumerate(e['dtds']): + src = 'DTD 首选' if k == 0 else 'DTD' + self.tv_modes.insert('', 'end', values=( + f"{t['hact']}x{t['vact']}", f"{t['refresh']}Hz", + f"{t['clk_khz'] / 1000:.2f}MHz", src)) + for m in e['cea_video']: + wh, _, hz = m.partition('@') + self.tv_modes.insert('', 'end', values=(wh, hz.split(' ')[0] + 'Hz', '', 'CEA-VIC')) + for m in e['est'] + e['std']: + wh, _, hz = m.partition('@') + src = '已建立时序' if m in e['est'] else '标准时序' + self.tv_modes.insert('', 'end', values=(wh, hz + 'Hz', '', src)) + self.txt_hex.configure(state='normal') + self.txt_hex.delete('1.0', 'end') + self.txt_hex.insert('1.0', hex_dump(d['edid'])) + self.txt_hex.configure(state='disabled') + + def _save_area(self): + self.area = self.area_var.get() + save_slots(self.slots, self.area) + + def _show_solid(self, color): + idx = self.cbo.current() + if idx < 0 or idx >= len(self.displays): + return + d = self.displays[idx] + if not d['rect']: + return + show_pattern(self, d['rect'], color, area=self.area_var.get()) + + def _apply_mode(self): + from tkinter import messagebox + idx, mi = self.cbo.current(), self.cbo_mode.current() + if idx < 0 or idx >= len(self.displays) or mi < 0: + return + dm = self._modes.get(self.cbo_mode.get()) + if dm is None: + return + dev = self.displays[idx]['device'] + if dev not in self._orig_modes: # 记住首次切改前的原始模式, 供"恢复"用 + cur = DEVMODEW() + cur.dmSize = ctypes.sizeof(cur) + if user32.EnumDisplaySettingsW(dev, ENUM_CURRENT_SETTINGS, ctypes.byref(cur)): + self._orig_modes[dev] = cur + ok, r = apply_mode(dev, dm) + if not ok: + messagebox.showerror('切换失败', f'ChangeDisplaySettingsEx 返回码 {r}') + return + self.after(500, self.load_displays) # 等模式生效后刷新矩形/EDID 面板 + + def _restore_mode(self): + from tkinter import messagebox + idx = self.cbo.current() + if idx < 0 or idx >= len(self.displays): + return + dev = self.displays[idx]['device'] + dm = self._orig_modes.pop(dev, None) + if dm is None: + messagebox.showinfo('无可恢复', '该显示器没有记录切改前的模式') + return + apply_mode(dev, dm) + self.after(500, self.load_displays) + + def _show_pattern(self, kind): + idx = self.cbo.current() + if idx < 0 or idx >= len(self.displays): + return + d = self.displays[idx] + if not d['rect']: + return + show_pattern(self, d['rect'], kind, area=self.area_var.get()) + + def _show_custom(self, idx): + from tkinter import messagebox + slot = self.slots[idx] + if not slot['file']: + messagebox.showinfo('未配置图片', + f'请先点击"⚙"为"{slot["name"]}"配置图片。') + return + path = os.path.join(BASE_DIR, slot['file']) + if not os.path.exists(path): + messagebox.showerror('图片不存在', path) + return + di = self.cbo.current() + if di < 0 or di >= len(self.displays) or not self.displays[di]['rect']: + return + show_pattern_image(self, self.displays[di]['rect'], path, + area=self.area_var.get()) + + def _configure_slot(self, idx): + from tkinter import filedialog, messagebox + dlg = tk.Toplevel(self) + dlg.title(f'配置自定义栏位 {idx + 1}') + dlg.resizable(False, False) + dlg.grab_set() + + name_var = tk.StringVar(value=self.slots[idx]['name']) + file_var = tk.StringVar(value=self.slots[idx]['file'] or '(未设置)') + + body = ttk.Frame(dlg, padding=10) + body.pack() + ttk.Label(body, text='按钮名称:').grid(row=0, column=0, sticky='w', pady=4) + ttk.Entry(body, textvariable=name_var, width=30).grid(row=0, column=1, columnspan=2, + sticky='w', padx=6) + ttk.Label(body, text='图片文件:').grid(row=1, column=0, sticky='w', pady=4) + ttk.Label(body, textvariable=file_var, foreground='#0066CC').grid( + row=1, column=1, columnspan=2, sticky='w', padx=6) + if not HAS_PIL: + ttk.Label(body, text='(未安装 Pillow, 仅支持 PNG/GIF; 安装 pillow 后可用 JPG/BMP/WebP)', + foreground='#B05000').grid(row=2, column=0, columnspan=3, sticky='w') + + def choose(): + exts = ' '.join(f'*{e}' for e in IMG_EXTS) + path = filedialog.askopenfilename(parent=dlg, title='选择图片', + filetypes=[('图片文件', exts), ('所有文件', '*.*')]) + if not path: + return + try: + file_var.set(copy_image_into_script_dir(path)) + except OSError as ex: + messagebox.showerror('复制图片失败', str(ex), parent=dlg) + + def clear(): + file_var.set('(未设置)') + + 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} + save_slots(self.slots, self.area_var.get()) + self.slot_buttons[idx].configure(text=name) + dlg.destroy() + + row = 3 if not HAS_PIL else 2 + ttk.Button(body, text='选择图片...', command=choose).grid( + row=row, column=0, sticky='w', pady=(8, 0)) + ttk.Button(body, text='清除图片', command=clear).grid( + row=row, column=1, sticky='w', padx=6, pady=(8, 0)) + ttk.Button(body, text='保存', command=save).grid( + row=row + 1, column=0, sticky='w', pady=(8, 0)) + ttk.Button(body, text='取消', command=dlg.destroy).grid( + row=row + 1, column=1, sticky='w', padx=6, pady=(8, 0)) + + app = App() + if auto_close > 0: + app.after(auto_close * 1000, app.destroy) + app.mainloop() + + +# ============================================================ +# 入口 +# ============================================================ +def run_dump(): + displays = get_displays() + if not displays: + print('未找到活动显示器') + return 1 + for i, d in enumerate(displays, 1): + print() + print(f'== 显示器 {i} {d["device"]} 显卡[{d["adapter"]}] ' + f'监视器[{d["mon_name"]}] 当前输出 {d["cur_mode"]}') + if not d['edid']: + print(' EDID 读取失败(注册表无 EDID 数据)') + continue + e = parse_edid(d['edid']) + for k, v in edid_info_lines(e): + print(f' {k:<14}: {v}') + print(' ---- 支持的模式 ----') + for k, t in enumerate(e['dtds']): + src = 'DTD1(首选)' if k == 0 else f'DTD{k + 1}' + print(f" [{src}] {t['hact']}x{t['vact']}@{t['refresh']} " + f"{t['clk_khz'] / 1000:.3f}MHz 总{t['htotal']}x{t['vtotal']}") + for m in e['cea_video']: + print(f' [CEA-VIC] {m}') + for m in e['cea_blocks']: + print(f' [CEA-块] {m}') + for m in e['est']: + print(f' [已建立] {m}') + for m in e['std']: + print(f' [标准] {m}') + print(' ---- EDID 原始数据 ----') + print(hex_dump(d['edid'])) + return 0 + + +def main(): + ap = argparse.ArgumentParser(description='HDMI EDID 查看器 + 测试图案') + ap.add_argument('--dump', action='store_true', help='控制台输出 EDID 解算结果, 不开 GUI') + ap.add_argument('--test-pattern', + choices=list(PATTERN_NAMES) + [f'Custom{i + 1}' for i in range(SLOT_COUNT)], + default=None, help='直接全屏出测试图案(不开主窗口; CustomN 为自定义栏位)') + ap.add_argument('--area', choices=list(AREA_OPTIONS), default=DEFAULT_AREA, + 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/图案冒烟测试)') + args = ap.parse_args() + + if args.dump: + if getattr(sys, 'frozen', False): + # --noconsole 打包的 exe 没有控制台可写, 引导用户用源码方式 + import tkinter as tk + from tkinter import messagebox + r = tk.Tk() + r.withdraw() + messagebox.showinfo('不支持', 'exe 版本无控制台输出。\n请用源码方式运行:\npython hdmi_gui.py --dump') + sys.exit(1) + sys.exit(run_dump()) + if args.test_pattern: + displays = get_displays() + if not displays: + print('未找到活动显示器') + sys.exit(1) + d = displays[min(args.monitor, len(displays)) - 1] + if not d['rect']: + print('无法获取显示器矩形') + sys.exit(1) + import tkinter as tk + root = tk.Tk() + root.withdraw() + if args.test_pattern.startswith('Custom'): + slots, _ = load_slots() + slot = slots[int(args.test_pattern[6:]) - 1] + if not slot['file']: + print(f"错误: {args.test_pattern}({slot['name']})未配置图片") + sys.exit(1) + show_pattern_image(root, d['rect'], os.path.join(BASE_DIR, slot['file']), + args.seconds, area=args.area) + else: + show_pattern(root, d['rect'], args.test_pattern, args.seconds, area=args.area) + sys.exit(0) + run_gui(args.seconds) + + +if __name__ == '__main__': + main() diff --git a/HDMI-Tool/hdmi_source.bat b/HDMI-Tool/hdmi_source.bat new file mode 100644 index 0000000..0cd1787 --- /dev/null +++ b/HDMI-Tool/hdmi_source.bat @@ -0,0 +1,7 @@ +@echo off +rem HDMI source test tool launcher (bypasses PowerShell execution policy) +rem Examples: +rem hdmi_source.bat -List +rem hdmi_source.bat -Mode 640x360 +rem hdmi_source.bat -Mode 640x360 -Pattern -Restore +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0hdmi_source.ps1" %* diff --git a/HDMI-Tool/hdmi_source.ps1 b/HDMI-Tool/hdmi_source.ps1 new file mode 100644 index 0000000..9804adb --- /dev/null +++ b/HDMI-Tool/hdmi_source.ps1 @@ -0,0 +1,487 @@ +<# +.SYNOPSIS + HDMI 源端测试工具 —— 枚举/切换输出分辨率 + 全屏测试图案 + +.DESCRIPTION + 配合 LT8619C(HDMI 转 RGB888)→ DLPC3421 转接板调试使用。 + + 原理:电脑显卡通过 HDMI DDC 读取转接板 EDID 来枚举可用分辨率, + 本脚本调用 Win32 API 完成模式枚举与切换,再用 WinForms 全屏窗口 + 输出测试图案。板载 EDID 只声明了 640x360@60(25.2MHz 像素时钟), + 插上 HDMI 后显卡应只能发这一个模式;-List 可直接验证 EDID 是否生效。 + + 测试图案用途: + Bars 100% 彩条 —— 肉眼/示波器验证 RGB 通道顺序与极性 + GraySteps 16 级灰阶 —— 验证 RGB888 各 bit 连通性 + Gradient R/G/B 三段渐变 —— 验证每通道 8bit 线性度 + Checker 黑白棋盘格 —— 验证像素时钟 PCLK 与 DE 对位 + Grid 网格+中心十字 —— 验证像素映射/偏移/缩放 + Red/Green/Blue/White/Black/Gray50 纯色 —— 万用表/示波器测单通道电平 + + 全屏窗口操作:空格或→下一图案,←上一图案,ESC 退出。 + +.EXAMPLE + tools\hdmi_source.bat -List + 枚举所有活动显示器与显卡支持的分辨率(验证 EDID) + +.EXAMPLE + tools\hdmi_source.bat -Monitor 1 -Mode 640x360 + 把显示器 1 切到 640x360(缺省 60Hz,可写 640x360@60) + +.EXAMPLE + tools\hdmi_source.bat -Mode 640x360 -Pattern -Restore + 切到 640x360 并全屏出图案,退出图案后恢复原分辨率 + +.EXAMPLE + tools\hdmi_source.bat -Pattern -PatternName Checker -Seconds 5 + 当前分辨率下全屏棋盘格,5 秒后自动退出(自动化测试用) +#> + +param( + [switch]$List, + [int]$Monitor = 1, + [string]$Mode = '', + [switch]$Pattern, + [ValidateSet('', 'Bars', 'GraySteps', 'Gradient', 'Checker', 'Grid', + 'Red', 'Green', 'Blue', 'White', 'Black', 'Gray50')] + [string]$PatternName = '', + [int]$Seconds = 0, + [switch]$Restore +) + +$ErrorActionPreference = 'Stop' + +# ---------------- Win32 显示 API ---------------- +Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; + +namespace HdmiSrc +{ + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)] + public struct DISPLAY_DEVICE + { + public int cb; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string DeviceName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string DeviceString; + public int StateFlags; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string DeviceID; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string DeviceKey; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)] + public struct DEVMODE + { + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string dmDeviceName; + public short dmSpecVersion; + public short dmDriverVersion; + public short dmSize; + public short dmDriverExtra; + public int dmFields; + public int dmPositionX; + public int dmPositionY; + public int dmDisplayOrientation; + public int dmDisplayFixedOutput; + public short dmColor; + public short dmDuplex; + public short dmYResolution; + public short dmTTOption; + public short dmCollate; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string dmFormName; + public short dmLogPixels; + public int dmBitsPerPel; + public int dmPelsWidth; + public int dmPelsHeight; + public int dmDisplayFlags; + public int dmDisplayFrequency; + public int dmICMMethod; + public int dmICMIntent; + public int dmMediaType; + public int dmDitherType; + public int dmReserved1; + public int dmReserved2; + public int dmPanningWidth; + public int dmPanningHeight; + } + + public static class Native + { + [DllImport("user32.dll", CharSet = CharSet.Ansi)] + public static extern bool EnumDisplayDevices(string lpDevice, uint iDevNum, + ref DISPLAY_DEVICE lpDisplayDevice, uint dwFlags); + + // PowerShell 会把 $null marshal 成空串而非 NULL,由 C# 侧保证传 NULL + public static bool EnumDisplayAdapters(uint iDevNum, ref DISPLAY_DEVICE lpDisplayDevice) + { + return EnumDisplayDevices(null, iDevNum, ref lpDisplayDevice, 0); + } + + [DllImport("user32.dll", CharSet = CharSet.Ansi)] + public static extern bool EnumDisplaySettings(string lpszDeviceName, int iModeNum, + ref DEVMODE lpDevMode); + + [DllImport("user32.dll", CharSet = CharSet.Ansi)] + public static extern int ChangeDisplaySettingsEx(string lpszDeviceName, + ref DEVMODE lpDevMode, IntPtr hwnd, int dwflags, IntPtr lParam); + } +} +"@ + +# 板载 EDID 声明的目标模式,-List 时用于自检 +$TargetW = 640 +$TargetH = 360 + +function New-DevMode { + $dm = New-Object HdmiSrc.DEVMODE + $dm.dmDeviceName = '' + $dm.dmFormName = '' + $dm.dmSize = [System.Runtime.InteropServices.Marshal]::SizeOf($dm) + return $dm +} + +function New-DisplayDevice { + $dd = New-Object HdmiSrc.DISPLAY_DEVICE + $dd.DeviceName = '' + $dd.DeviceString = '' + $dd.DeviceID = '' + $dd.DeviceKey = '' + $dd.cb = [System.Runtime.InteropServices.Marshal]::SizeOf($dd) + return $dd +} + +# 枚举所有"已连接到桌面"的显示器(即插着 HDMI 的输出口) +function Get-Displays { + $list = @() + $i = 0 + while ($true) { + $dd = New-DisplayDevice + if (-not [HdmiSrc.Native]::EnumDisplayAdapters($i, [ref]$dd)) { break } + # StateFlags bit0 = ATTACHED_TO_DESKTOP + if ($dd.StateFlags -band 0x1) { + $mon = New-DisplayDevice + [void][HdmiSrc.Native]::EnumDisplayDevices($dd.DeviceName, 0, [ref]$mon, 0) + $list += [pscustomobject]@{ + Index = $list.Count + 1 + Device = $dd.DeviceName + Adapter = $dd.DeviceString + MonName = $mon.DeviceString + } + } + $i++ + } + return $list +} + +# 显卡枚举到的全部显示模式(去重) +function Get-Modes { + param([string]$Device) + $modes = @() + $i = 0 + while ($true) { + $dm = New-DevMode + if (-not [HdmiSrc.Native]::EnumDisplaySettings($Device, $i, [ref]$dm)) { break } + $modes += [pscustomobject]@{ + W = $dm.dmPelsWidth + H = $dm.dmPelsHeight + Hz = $dm.dmDisplayFrequency + Bpp = $dm.dmBitsPerPel + } + $i++ + } + return ($modes | Sort-Object W, H, Hz, Bpp -Unique) +} + +function Get-CurrentDevMode { + param([string]$Device) + $dm = New-DevMode + [void][HdmiSrc.Native]::EnumDisplaySettings($Device, -1, [ref]$dm) # ENUM_CURRENT_SETTINGS + return $dm +} + +# 应用一个 DEVMODE(先 CDS_TEST 再真正生效),返回 $true/$false +function Set-DevMode { + param([string]$Device, $dm) + $dm.dmFields = 0x00040000 -bor 0x00080000 -bor 0x00100000 -bor 0x00400000 + # DM_BITSPERPEL | DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY + $rc = [HdmiSrc.Native]::ChangeDisplaySettingsEx($Device, [ref]$dm, [IntPtr]::Zero, 2, [IntPtr]::Zero) + if ($rc -ne 0) { + Write-Host (" 错误: 显卡拒绝该模式 (CDS_TEST 返回 {0})" -f $rc) -ForegroundColor Red + return $false + } + $rc = [HdmiSrc.Native]::ChangeDisplaySettingsEx($Device, [ref]$dm, [IntPtr]::Zero, 0, [IntPtr]::Zero) + if ($rc -ne 0) { + Write-Host (" 错误: 模式应用失败 (返回 {0})" -f $rc) -ForegroundColor Red + return $false + } + return $true +} + +# 在显卡枚举的模式里找 W x H [@Hz] 并切换 +function Switch-Mode { + param([string]$Device, [int]$W, [int]$H, [int]$Hz) + $all = @(Get-Modes $Device) + $hit = @($all | Where-Object { $_.W -eq $W -and $_.H -eq $H -and ($Hz -le 0 -or $_.Hz -eq $Hz) }) + if (-not $hit) { + Write-Host (" 错误: 显卡未枚举到 {0}x{1} 模式" -f $W, $H) -ForegroundColor Red + Write-Host " 可能原因:" -ForegroundColor Yellow + Write-Host " 1. 转接板 EDID 未被读取(检查 HPD / DDC / LT8619C 供电)" + Write-Host " 2. EDID 中未包含该分辨率(当前板载 EDID 只有 640x360@60)" + Write-Host " 3. 显卡驱动拒收该 DTD(可用 CRU 手动添加自定义分辨率)" + Write-Host " 先运行 -List 查看显卡实际枚举到了哪些模式。" + return $false + } + $dm = New-DevMode + $dm.dmPelsWidth = $W + $dm.dmPelsHeight = $H + $dm.dmDisplayFrequency = $hit[0].Hz + $dm.dmBitsPerPel = 32 + $ok = Set-DevMode $Device $dm + if ($ok) { + Write-Host (" 已切换 {0} -> {1}x{2}@{3}" -f $Device, $W, $H, $hit[0].Hz) -ForegroundColor Green + } + return $ok +} + +# ---------------- 全屏测试图案 ---------------- +function Show-PatternWindow { + param( + [string]$Device = '', + [string]$StartPattern = '', + [int]$AutoSeconds = 0 + ) + + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + + # 窗口放到目标设备名对应的屏幕上(如 \\.\DISPLAY2),找不到则回退主屏 + $screen = [System.Windows.Forms.Screen]::AllScreens | + Where-Object { $_.DeviceName -eq $Device } | + Select-Object -First 1 + if (-not $screen) { $screen = [System.Windows.Forms.Screen]::PrimaryScreen } + + $script:patterns = @('Bars', 'GraySteps', 'Gradient', 'Checker', 'Grid', + 'Red', 'Green', 'Blue', 'White', 'Black', 'Gray50') + $script:pi = 0 + if ($StartPattern) { + $idx = [array]::IndexOf($script:patterns, $StartPattern) + if ($idx -ge 0) { $script:pi = $idx } + } + + $script:form = New-Object System.Windows.Forms.Form + $script:form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None + $script:form.StartPosition = [System.Windows.Forms.FormStartPosition]::Manual + $script:form.Bounds = $screen.Bounds + $script:form.TopMost = $true + $script:form.KeyPreview = $true + # 双缓冲避免棋盘格/网格闪烁 + $script:form.GetType().GetProperty('DoubleBuffered', + [System.Reflection.BindingFlags]'Instance,NonPublic').SetValue($script:form, $true, $null) + + $osdFont = New-Object System.Drawing.Font('Consolas', 11) + + $paintHandler = { + param($s, $e) + $g = $e.Graphics + $w = $e.ClipRectangle.Width + $h = $e.ClipRectangle.Height + $name = $script:patterns[$script:pi] + + switch ($name) { + 'Bars' { + # 100% 饱和度竖彩条:白黄青绿品红红蓝黑 + $c = @(@(255,255,255), @(255,255,0), @(0,255,255), @(0,255,0), + @(255,0,255), @(255,0,0), @(0,0,255), @(0,0,0)) + $n = $c.Count + for ($k = 0; $k -lt $n; $k++) { + $bw = [Math]::Ceiling($w / $n) + $brush = New-Object System.Drawing.SolidBrush ( + [System.Drawing.Color]::FromArgb($c[$k][0], $c[$k][1], $c[$k][2])) + $g.FillRectangle($brush, [int]($k * $bw), 0, [int]$bw + 1, $h) + $brush.Dispose() + } + } + 'GraySteps' { + # 16 级灰阶,验证 RGB888 各 bit + $n = 16 + for ($k = 0; $k -lt $n; $k++) { + $v = [int]($k * 255 / ($n - 1)) + $bw = [Math]::Ceiling($w / $n) + $brush = New-Object System.Drawing.SolidBrush ( + [System.Drawing.Color]::FromArgb($v, $v, $v)) + $g.FillRectangle($brush, [int]($k * $bw), 0, [int]$bw + 1, $h) + $brush.Dispose() + } + } + 'Gradient' { + # R/G/B 三段水平渐变,验证每通道 8bit 线性度 + $seg = [Math]::Ceiling($w / 3.0) + $rgbIdx = @(0, 1, 2) + foreach ($ch in $rgbIdx) { + for ($x = 0; $x -lt $seg; $x++) { + $v = [int](255 * $x / [Math]::Max(1.0, ($seg - 1))) + $r = 0; $gg = 0; $b = 0 + if ($ch -eq 0) { $r = $v } elseif ($ch -eq 1) { $gg = $v } else { $b = $v } + $brush = New-Object System.Drawing.SolidBrush ( + [System.Drawing.Color]::FromArgb($r, $gg, $b)) + $g.FillRectangle($brush, [int]($ch * $seg + $x), 0, 1, $h) + $brush.Dispose() + } + } + } + 'Checker' { + # 黑白棋盘格,验证 PCLK/DE 对位 + $g.FillRectangle([System.Drawing.Brushes]::Black, 0, 0, $w, $h) + $cell = 16 + for ($y = 0; $y -lt $h; $y += $cell) { + for ($x = 0; $x -lt $w; $x += $cell) { + if ((([int]($x / $cell) + [int]($y / $cell)) % 2) -eq 0) { + $g.FillRectangle([System.Drawing.Brushes]::White, $x, $y, $cell, $cell) + } + } + } + } + 'Grid' { + # 黑底 + 40px 网格 + 中心十字 + 白色边框,验证像素映射 + $g.FillRectangle([System.Drawing.Brushes]::Black, 0, 0, $w, $h) + $pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(0, 128, 0), 1) + for ($x = 40; $x -lt $w; $x += 40) { $g.DrawLine($pen, $x, 0, $x, $h) } + for ($y = 40; $y -lt $h; $y += 40) { $g.DrawLine($pen, 0, $y, $w, $y) } + $pen.Dispose() + $wp = New-Object System.Drawing.Pen ([System.Drawing.Color]::White, 2) + $cx = [int]($w / 2); $cy = [int]($h / 2) + $g.DrawLine($wp, $cx, 0, $cx, $h) + $g.DrawLine($wp, 0, $cy, $w, $cy) + $g.DrawRectangle($wp, 1, 1, $w - 3, $h - 3) + $wp.Dispose() + } + 'Red' { $g.FillRectangle([System.Drawing.Brushes]::Red, 0, 0, $w, $h) } + 'Green' { $g.FillRectangle([System.Drawing.Brushes]::Green, 0, 0, $w, $h) } + 'Blue' { $g.FillRectangle([System.Drawing.Brushes]::Blue, 0, 0, $w, $h) } + 'White' { $g.FillRectangle([System.Drawing.Brushes]::White, 0, 0, $w, $h) } + 'Black' { $g.FillRectangle([System.Drawing.Brushes]::Black, 0, 0, $w, $h) } + 'Gray50' { + $brush = New-Object System.Drawing.SolidBrush ( + [System.Drawing.Color]::FromArgb(128, 128, 128)) + $g.FillRectangle($brush, 0, 0, $w, $h) + $brush.Dispose() + } + } + + # 左上角 OSD:图案名 + 分辨率 + 按键提示 + $text = "[{0}] {1}x{2} 空格/→:下一图案 ←:上一图案 ESC:退出" -f $name, $w, $h + $sz = $g.MeasureString($text, $osdFont) + $bg = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(150, 0, 0, 0)) + $g.FillRectangle($bg, 6, 6, $sz.Width + 12, $sz.Height + 8) + $bg.Dispose() + $g.DrawString($text, $osdFont, [System.Drawing.Brushes]::White, 12, 10) + } + $script:form.Add_Paint($paintHandler) + + $script:form.Add_KeyDown({ + param($s, $e) + switch ($e.KeyCode) { + 'Escape' { $s.Close() } + 'Space' { $script:pi = ($script:pi + 1) % $script:patterns.Count; $s.Invalidate() } + 'Right' { $script:pi = ($script:pi + 1) % $script:patterns.Count; $s.Invalidate() } + 'Left' { $script:pi = ($script:pi - 1 + $script:patterns.Count) % $script:patterns.Count; $s.Invalidate() } + } + }) + + $script:form.Add_Activated({ [System.Windows.Forms.Cursor]::Hide() }) + $script:form.Add_FormClosed({ [System.Windows.Forms.Cursor]::Show() }) + + if ($AutoSeconds -gt 0) { + $timer = New-Object System.Windows.Forms.Timer + $timer.Interval = $AutoSeconds * 1000 + $timer.Add_Tick({ $script:form.Close() }) + $timer.Start() + } + + Write-Host ("全屏图案已启动于 {0} ({1}x{2})" -f $screen.DeviceName, $screen.Bounds.Width, $screen.Bounds.Height) + Write-Host "空格/→:下一图案 ←:上一图案 ESC:退出" + [void]$script:form.ShowDialog() +} + +function Show-Usage { + Write-Host @" +用法: + hdmi_source.bat -List 枚举显示器与可用分辨率(验证 EDID) + hdmi_source.bat -Monitor -Mode 切换分辨率, 如 -Mode 640x360 或 640x360@60 + hdmi_source.bat [-Monitor ] -Pattern [-PatternName <名>] [-Seconds ] + 全屏测试图案(Bar/GraySteps/Gradient/Checker/Grid/纯色) + 可选: -Restore 退出图案窗口后恢复切换前的分辨率 + -Seconds 图案 n 秒后自动退出(自动化测试) + +典型调试流程: + 1. HDMI 插上转接板 -> -List 确认出现 640x360(EDID 生效) + 2. -Mode 640x360 -Pattern -> 全屏图案, 逐项验证 RGB 通道/位序/像素映射 +"@ +} + +# ---------------- 主流程 ---------------- +$displays = @(Get-Displays) # @() 保证单显示器时不退化为标量 +if ($displays.Count -eq 0) { + Write-Host "错误: 未找到活动显示器" -ForegroundColor Red + exit 1 +} + +if ($List) { + foreach ($d in $displays) { + Write-Host "" + Write-Host ("== 显示器 {0} {1} 显卡[{2}] 监视器[{3}]" -f $d.Index, $d.Device, $d.Adapter, $d.MonName) -ForegroundColor Cyan + $cur = Get-CurrentDevMode $d.Device + Write-Host (" 当前模式: {0}x{1}@{2} {3}bpp" -f $cur.dmPelsWidth, $cur.dmPelsHeight, $cur.dmDisplayFrequency, $cur.dmBitsPerPel) + $modes = Get-Modes $d.Device + $modes | Format-Table W, H, Hz, Bpp -AutoSize + $hasTarget = $modes | Where-Object { $_.W -eq $TargetW -and $_.H -eq $TargetH } + if ($hasTarget) { + Write-Host (" [OK] 找到板载 EDID 目标模式 {0}x{1} -> EDID 已被显卡读取" -f $TargetW, $TargetH) -ForegroundColor Green + } else { + Write-Host (" [--] 未找到 {0}x{1} (若此口接的是转接板, 说明 EDID/HPD 未生效)" -f $TargetW, $TargetH) -ForegroundColor Yellow + } + } + exit 0 +} + +if (-not $Mode -and -not $Pattern) { + Show-Usage + exit 0 +} + +if ($Monitor -gt $displays.Count) { + Write-Host ("错误: -Monitor 超出范围(共 {0} 个活动显示器, 运行 -List 查看)" -f $displays.Count) -ForegroundColor Red + exit 1 +} +$disp = $displays[$Monitor - 1] +Write-Host ("目标: 显示器 {0} = {1}" -f $disp.Index, $disp.Device) + +$prevMode = $null +if ($Mode) { + if ($Mode -notmatch '^\s*(\d+)\s*[xX]\s*(\d+)\s*(?:@\s*(\d+)\s*)?$') { + Write-Host "错误: -Mode 格式应为 WxH 或 WxH@Hz, 例如 640x360 或 640x360@60" -ForegroundColor Red + exit 1 + } + $w = [int]$Matches[1]; $h = [int]$Matches[2] + $hz = 0 + if ($Matches[3]) { $hz = [int]$Matches[3] } + + $prevMode = Get-CurrentDevMode $disp.Device + $ok = Switch-Mode $disp.Device $w $h $hz + if (-not $ok) { exit 1 } + Start-Sleep -Milliseconds 300 # 等显卡/显示器完成模式切换 +} + +if ($Pattern) { + if ($Restore -and -not $prevMode) { + Write-Host "提示: -Restore 仅在与 -Mode 同用时生效" -ForegroundColor Yellow + } + try { + Show-PatternWindow -Device $disp.Device -StartPattern $PatternName -AutoSeconds $Seconds + } + finally { + if ($Restore -and $prevMode) { + Write-Host ("恢复原分辨率 {0}x{1}@{2} ..." -f $prevMode.dmPelsWidth, $prevMode.dmPelsHeight, $prevMode.dmDisplayFrequency) + [void](Set-DevMode $disp.Device $prevMode) + } + } +} diff --git a/HDMI-Tool/logos/logo_ph_orange.ico b/HDMI-Tool/logos/logo_ph_orange.ico new file mode 100644 index 0000000..3713f83 Binary files /dev/null and b/HDMI-Tool/logos/logo_ph_orange.ico differ diff --git a/HDMI-Tool/logos/logo_ph_orange.png b/HDMI-Tool/logos/logo_ph_orange.png new file mode 100644 index 0000000..7ee6fcf Binary files /dev/null and b/HDMI-Tool/logos/logo_ph_orange.png differ diff --git a/HDMI-Tool/logos/logo_ph_orange.svg b/HDMI-Tool/logos/logo_ph_orange.svg new file mode 100644 index 0000000..af4af1f --- /dev/null +++ b/HDMI-Tool/logos/logo_ph_orange.svg @@ -0,0 +1,6 @@ + + + + HDMI + Tool + diff --git a/HDMI-Tool/logos/make_ico.py b/HDMI-Tool/logos/make_ico.py new file mode 100644 index 0000000..6536889 --- /dev/null +++ b/HDMI-Tool/logos/make_ico.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +把 logo_ph_orange.svg 同款设计栅格化为多尺寸 .ico(供 exe 图标) +================================================================ +设计参数与 SVG 严格一致: 黑底 512, 橙色圆角矩形 x38 y64 w436 h186 rx34, +HDMI 146px Arial Bold 黑字(框内居中), Tool 146px 白字(基线 421)。 + +输出: logos/logo_ph_orange.png(512, 预览/复用) + logos/logo_ph_orange.ico(16~256 七档, exe 图标用) + +用法: python make_ico.py +""" + +import os + +from PIL import Image, ImageDraw, ImageFont + +BASE = os.path.dirname(os.path.abspath(__file__)) +FONT = r'C:\Windows\Fonts\arialbd.ttf' # Arial Bold, Windows 标配 + + +def render(size=512): + s = size / 512.0 + img = Image.new('RGB', (size, size), (0, 0, 0)) + d = ImageDraw.Draw(img) + d.rounded_rectangle([38 * s, 64 * s, (38 + 436) * s, (64 + 186) * s], + radius=34 * s, fill=(255, 153, 0)) + if not os.path.exists(FONT): + 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') + 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') + img.save(png) + img.save(ico, sizes=[(16, 16), (24, 24), (32, 32), (48, 48), + (64, 64), (128, 128), (256, 256)]) + print(f'写出 {png}') + print(f'写出 {ico}') diff --git a/HDMI-Tool/pattern_slots.json b/HDMI-Tool/pattern_slots.json new file mode 100644 index 0000000..f49fce4 --- /dev/null +++ b/HDMI-Tool/pattern_slots.json @@ -0,0 +1,17 @@ +{ + "area": "800x600", + "slots": [ + { + "name": "西门子星", + "file": "bw_star.png" + }, + { + "name": "频率条纹", + "file": "bw_sweep.png" + }, + { + "name": "渐变棋盘", + "file": "bw_checker_sweep.png" + } + ] +} \ No newline at end of file diff --git a/HDMI-Tool/solid_000055.png b/HDMI-Tool/solid_000055.png new file mode 100644 index 0000000..5573980 Binary files /dev/null and b/HDMI-Tool/solid_000055.png differ diff --git a/HDMI-Tool/solid_0000AA.png b/HDMI-Tool/solid_0000AA.png new file mode 100644 index 0000000..5a4bf49 Binary files /dev/null and b/HDMI-Tool/solid_0000AA.png differ diff --git a/HDMI-Tool/solid_0000FF.png b/HDMI-Tool/solid_0000FF.png new file mode 100644 index 0000000..87d7f56 Binary files /dev/null and b/HDMI-Tool/solid_0000FF.png differ diff --git a/HDMI-Tool/solid_005500.png b/HDMI-Tool/solid_005500.png new file mode 100644 index 0000000..1a3f916 Binary files /dev/null and b/HDMI-Tool/solid_005500.png differ diff --git a/HDMI-Tool/solid_00AA00.png b/HDMI-Tool/solid_00AA00.png new file mode 100644 index 0000000..0b858c3 Binary files /dev/null and b/HDMI-Tool/solid_00AA00.png differ diff --git a/HDMI-Tool/solid_00FF00.png b/HDMI-Tool/solid_00FF00.png new file mode 100644 index 0000000..0f13f69 Binary files /dev/null and b/HDMI-Tool/solid_00FF00.png differ diff --git a/HDMI-Tool/solid_550000.png b/HDMI-Tool/solid_550000.png new file mode 100644 index 0000000..efcd1b2 Binary files /dev/null and b/HDMI-Tool/solid_550000.png differ diff --git a/HDMI-Tool/solid_AA0000.png b/HDMI-Tool/solid_AA0000.png new file mode 100644 index 0000000..7be3495 Binary files /dev/null and b/HDMI-Tool/solid_AA0000.png differ diff --git a/HDMI-Tool/solid_FF0000.png b/HDMI-Tool/solid_FF0000.png new file mode 100644 index 0000000..8f5a718 Binary files /dev/null and b/HDMI-Tool/solid_FF0000.png differ diff --git a/I2C-Inject/README.md b/I2C-Inject/README.md new file mode 100644 index 0000000..46d422f --- /dev/null +++ b/I2C-Inject/README.md @@ -0,0 +1,70 @@ +# I2C-Inject — LT8619C 寄存器择机注入工具 + +> 更新:2026-09-03。由两部分组成:**RP2040 注入器固件**(`rp2040_inject.py`,刷入 +> BPI-Pico-RP2040,MicroPython)+ **PC 端 GUI**(`i2c_inject_gui.py` / 打包版 +> `I2C-Inject.exe`)。可在 LT8619C demo 板的 I2C 总线上安全地在线读写寄存器。 + +--- + +## 1. 工作原理(为什么不会和 demo 板冲突) + +demo 板 MCU 每 ~500ms 轮询一次 LT8619C。注入器的时序: + +``` +监听总线边沿 -> 等下一轮活动结束 -> 总线静置 50ms -> 发射 +(切 bank -> 写 -> 读回校验), 失败自动重试 3 次 -> 立即释放总线回监听 +``` + +静置窗口落在两次轮询之间的 ~490ms 空窗内,单次注入序列 <2ms,冲突概率趋近零; +即使撞上也表现为读回不符,自动重试覆盖。板载 LED:**常亮 = 监听中**,上电快闪 3 次 = 自检。 + +## 2. 硬件准备 + +| RP2040 板 | 接 LT8619C 总线 | +|---|---| +| GP8(物理脚 11) | SDA | +| GP9(物理脚 12) | SCL | +| GND | GND | + +- **不接 VCC、不外加干扰**:总线由 demo 板自带上拉(先确认上拉为 3.3V) +- 固件刷写:板子插 USB → 已有 MicroPython 则直接 + `py -m pip install mpremote && py -m mpremote connect COMx cp rp2040_inject.py :main.py` + → 复位后自启动(刷新 MicroPython UF2 会清掉 main.py,重传一次即可) +- 固件内置 viper 编译的总线嗅探(`c [ms]` 命令,~3MHz 采样),串口回吐边沿数据可离线解码 I2C + +## 3. GUI 使用 + +双击 `I2C-Inject.exe`(或 `python i2c_inject_gui.py [COMx]`): + +1. **第一排左侧**:串口下拉 →"刷新"扫描 → 选中注入器端口 →"连接"(日志出现"已连接") +2. **第一排右侧**:输入 Bank/寄存器/值(十六进制)→ **写入**(自动读回校验)/**读取** +3. **0x606E 快捷块**(位交换,语义为 demo 板 LA 实测): + - `00`=关交换;`20`=镜像 D[7:0](载蓝色分量的组,实测立即生效) + - `40`/`80`=另外两组(demo 板未单独实测,自板点亮时验证) +4. **0x606D 快捷块**(RGB 整组顺序,蓝+红双色实测=手册 naive 读法): + - `00 BGR / 03 BRG / 04 GBR / 05 GRB / 06 RBG / 07 RGB` + - 字母串顺序 = D[23:16]/D[15:8]/D[7:0] 携带的分量;切换实时生效,不影响 HDMI 锁定 +5. 回显格式:`=` 正常结果(绿色,含"读回=xx"校验值)、`!` 告警/失败(橙色) +6. 收尾:把改过的寄存器写回原值(0x606E 原值 00;0x606D 原值 07) + +## 4. 已实测的寄存器事实速查(demo 板) + +| 寄存器 | 实测结论 | +|---|---| +| 0x606E | bit5(0x20)=镜像 D[7:0](蓝值组),立即生效、含消隐码;bit7(0x80) 对该组无效;稳态下写入可持续(实测 6.7s+),板子非稳态时 0.3~0.5s 内被刷回 00 → **先投图等稳态再写,写完立即读回确认** | +| 0x606D | 000/001/010=BGR、011=BRG、100=GBR、101=GRB、110=RBG、111=RGB(字母串=D[23:16]/D[15:8]/D[7:0]),切换实时生效;demo 板 init 值 07 | + +## 5. 目录内容 + +| 文件 | 用途 | +|---|---| +| `I2C-Inject.exe` | 打包好的单文件 PC 端 GUI(需 pyserial,已打包在内) | +| `i2c_inject_gui.py` | PC 端 GUI 源码(`python build_exe.py` 重新打包) | +| `rp2040_inject.py` | RP2040 注入器固件(MicroPython,刷成 main.py 自启动) | +| `logos/` | 图标源文件与 `make_ico_i2c.py`(改 logo 后重跑再打包) | + +## 6. 故障排查 + +- 写入回显 `OK 读回=xx` 但稍后读回变了 → demo 板把它刷回了(见 §4 稳态提醒),先确认 HDMI 已稳定出图 +- 读寄存器 NAK/失败 → 检查三根接线与共地;用 `s` 命令看边沿计数是否在增长(增长=监听正常,总线有活动) +- 串口列表空白 → 换 USB 口/检查设备管理器(应出现 `USB 串行设备`,VID_2E8A) diff --git a/I2C-Inject/build_exe.py b/I2C-Inject/build_exe.py new file mode 100644 index 0000000..244ea22 --- /dev/null +++ b/I2C-Inject/build_exe.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +把 i2c_inject_gui.py 打包成单文件 exe(工具名: I2C-Inject) +================================ +使用 PyInstaller --onefile --noconsole, 目标机器无需安装 Python/pyserial。 + +用法: + python build_exe.py 自动安装 PyInstaller(如缺)并打包 + +产物: 本目录 dist/I2C-Inject.exe +""" + +import os +import subprocess +import sys + +BASE = os.path.dirname(os.path.abspath(__file__)) +ICO = os.path.join(BASE, 'logos', 'logo_i2c_orange.ico') + + +def ensure_pyinstaller(): + try: + import PyInstaller # noqa: F401 + return + except ImportError: + pass + print('PyInstaller 未安装, 正在安装...') + subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'pyinstaller']) + + +def main(): + ensure_pyinstaller() + exe = os.path.join(BASE, 'dist', 'I2C-Inject.exe') + if os.path.exists(exe): + try: + os.remove(exe) + except PermissionError: + subprocess.run(['taskkill', '/F', '/IM', 'I2C-Inject.exe'], + capture_output=True) + import time + time.sleep(1.0) + os.remove(exe) + cmd = [sys.executable, '-m', 'PyInstaller', + '--onefile', '--clean', '--name', 'I2C-Inject', + '--distpath', os.path.join(BASE, 'dist'), + '--workpath', os.path.join(BASE, 'build'), + '--specpath', os.path.join(BASE, 'build'), + '--noconsole', os.path.join(BASE, 'i2c_inject_gui.py')] + if os.path.exists(ICO): + # exe 文件图标 + 运行时窗口图标资源(从 _MEIPASS 读取) + cmd += ['--icon', ICO, '--add-data', f'{ICO};.'] + else: + print('警告: 未找到 logos/logo_i2c_orange.ico(先运行 logos/make_ico_i2c.py), 本次打包不带图标') + print('>>', ' '.join(cmd)) + subprocess.check_call(cmd) + print('完成: %s (%.1f MB)' % (exe, os.path.getsize(exe) / 1024 / 1024)) + + +if __name__ == '__main__': + main() diff --git a/I2C-Inject/i2c_inject_gui.py b/I2C-Inject/i2c_inject_gui.py new file mode 100644 index 0000000..6d65b74 --- /dev/null +++ b/I2C-Inject/i2c_inject_gui.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +I2C-Inject — LT8619C 寄存器择机注入工具 +================================================ +配合 RP2040 注入器固件(tools/rp2040_inject.py, 刷入 BPI-Pico-RP2040)使用, +在 LT8619C demo 板的 I2C 总线上择机读写寄存器。 + +特性: + - 串口选择/连接 与 寄存器操作 并列第一排, 手动输入 bank/寄存器/值 即刻注入 + - 择机机制: 等总线一轮活动结束 -> 总线静置 50ms -> 发射(切bank->写->读回校验), + 失败自动重试 3 次, 完成后立即释放总线回监听 + - 0x606E(位交换)/0x606D(整组顺序) 快捷按钮, 全部为 demo 板 LA 实测确认的语义 + +依赖: pyserial(py -m pip install pyserial) + tkinter 标准库 +用法: python i2c_inject_gui.py (也可带默认串口参数: python i2c_inject_gui.py COM9) +打包: python build_exe.py (生成 I2C-Inject.exe) +""" + +import serial +import serial.tools.list_ports +import os +import sys +import threading +import queue +import time +import tkinter as tk +from tkinter import ttk, messagebox + +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__)) + + +def get_icon_path(): + """工具图标: 源码模式取 logos/ 下, 打包模式取 exe 内嵌资源(_MEIPASS)""" + if getattr(sys, 'frozen', False): + p = os.path.join(getattr(sys, '_MEIPASS', ''), 'logo_i2c_orange.ico') + else: + p = os.path.join(BASE_DIR, 'logos', 'logo_i2c_orange.ico') + return p if os.path.exists(p) else None + + +class Injector: + """串口工作线程: 目标端口动态切换; 命令队列 -> RP2040, 回显 -> 消息队列""" + + def __init__(self, on_line, on_status): + self.q = queue.Queue() + self.on_line, self.on_status = on_line, on_status + self._target = None # 想要连接的端口名(None=断开) + self.ser = None + self._lock = threading.Lock() + threading.Thread(target=self._run, daemon=True).start() + + def connect(self, port): + with self._lock: + self._target = port + + def disconnect(self): + with self._lock: + self._target = None + + def send(self, cmd): + with self._lock: + if self.ser is None: + self.on_status('未连接串口, 命令被丢弃: ' + cmd) + return + self.q.put(cmd) + + def is_connected(self): + with self._lock: + return self.ser is not None + + def _run(self): + buf = b'' + while True: + with self._lock: + target = self._target + if target != (self.ser.port if self.ser else None): + 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, timeout=0.2) + buf = b'' + time.sleep(0.3) # 等 Pico 复位横幅打完 + self.ser.reset_input_buffer() + self.on_status('已连接 ' + target) + except Exception as e: + self.ser = None + with self._lock: + self._target = None + self.on_status('连接 %s 失败: %s' % (target, e)) + if self.ser is not None: + try: + cmd = None + try: + cmd = self.q.get_nowait() + except queue.Empty: + pass + if cmd is not None: + self.ser.write((cmd + '\n').encode()) + buf += self.ser.read(256) + while b'\n' in buf: + ln, buf = buf.split(b'\n', 1) + txt = ln.decode('utf-8', 'replace').strip() + if txt: + self.on_line(txt) + except OSError: + self.ser = None + with self._lock: + self._target = None + self.on_status('串口丢失(设备拔出?), 请重新连接') + time.sleep(0.02) + + +def run_gui(default_port=None): + root = tk.Tk() + root.title('I2C-Inject — LT8619C 寄存器择机注入器 (静置50ms)') + root.geometry('860x520') + ic = get_icon_path() + if ic: + try: + root.iconbitmap(ic) # 窗口标题栏图标 + except Exception: + pass + + log_q = queue.Queue() + + # ============ 第一排: 串口选择 + 寄存器操作(并列) ============ + row1 = ttk.LabelFrame(root, text='连接与寄存器操作 (十六进制)', padding=(8, 4)) + row1.pack(fill='x', padx=8, pady=(8, 4)) + + # --- 左半: 串口选择 --- + ttk.Label(row1, text='串口:').grid(row=0, column=0) + cbo_port = ttk.Combobox(row1, state='readonly', width=8, values=[]) + cbo_port.grid(row=0, column=1, padx=(2, 6)) + + inj = Injector(on_line=lambda s: log_q.put((s, 'rx')), + on_status=lambda s: log_q.put((s, 'st'))) + + def refresh_ports(): + ports = [p.device for p in serial.tools.list_ports.comports()] + cbo_port['values'] = ports + if ports: + cbo_port.current(0) + else: + cbo_port.set('') + inj.on_status('串口列表: %s' % (', '.join(ports) if ports else '无')) + + def toggle_connect(): + if inj.is_connected(): + inj.disconnect() + btn_conn.config(text='连接') + else: + port = cbo_port.get().strip() + if not port: + messagebox.showerror('未选择串口', '请先选择串口(或点刷新扫描)') + return + inj.connect(port) + btn_conn.config(text='断开') + + ttk.Button(row1, text='刷新', width=6, command=refresh_ports).grid(row=0, column=2, padx=(0, 4)) + btn_conn = ttk.Button(row1, text='连接', width=6, command=toggle_connect) + btn_conn.grid(row=0, column=3, padx=(0, 14)) + + # --- 右半: 寄存器操作 --- + ttk.Label(row1, text='Bank:').grid(row=0, column=4) + e_bank = ttk.Entry(row1, width=5); e_bank.insert(0, '60'); e_bank.grid(row=0, column=5, padx=(2, 8)) + ttk.Label(row1, text='寄存器:').grid(row=0, column=6) + e_reg = ttk.Entry(row1, width=5); e_reg.insert(0, '6E'); e_reg.grid(row=0, column=7, padx=(2, 8)) + ttk.Label(row1, text='值:').grid(row=0, column=8) + e_val = ttk.Entry(row1, width=5); e_val.insert(0, '00'); e_val.grid(row=0, column=9, padx=(2, 8)) + + def _hex_ok(s): + try: + return 0 <= int(s, 16) <= 0xFF + except ValueError: + return False + + def do_write(): + b, r, v = e_bank.get().strip(), e_reg.get().strip(), e_val.get().strip() + if not (_hex_ok(b) and _hex_ok(r) and _hex_ok(v)): + messagebox.showerror('输入错误', 'bank/寄存器/值 都必须是 00-FF 的十六进制') + return + inj.send('w %s %s %s' % (b, r, v)) + + def do_read(): + b, r = e_bank.get().strip(), e_reg.get().strip() + if not (_hex_ok(b) and _hex_ok(r)): + messagebox.showerror('输入错误', 'bank/寄存器 必须是 00-FF 的十六进制') + return + inj.send('r %s %s' % (b, r)) + + ttk.Button(row1, text='写入', width=8, command=do_write).grid(row=0, column=10, padx=2) + ttk.Button(row1, text='读取', width=8, command=do_read).grid(row=0, column=11, padx=2) + ttk.Label(row1, text='写=择机写入后自动读回校验; 失败自动重试3次', + foreground='#666666').grid(row=1, column=0, columnspan=12, + sticky='w', pady=(4, 0)) + + # ============ 0x606E 快捷块(位交换) ============ + qk = ttk.LabelFrame(root, text='0x606E 快捷操作 (位交换)', padding=(8, 4)) + qk.pack(fill='x', padx=8, pady=4) + for i, v in enumerate(('00', '20', '40', '80')): + ttk.Button(qk, text='写 ' + v, width=8, + command=lambda v=v: (e_val.delete(0, 'end'), e_val.insert(0, v), do_write()) + ).grid(row=0, column=i, padx=4) + ttk.Button(qk, text='读 0x606E', width=10, + command=do_read).grid(row=0, column=5, padx=4) + ttk.Label(qk, text='00=关交换 20=镜像D[7:0](LA实测) 40/80=另外两组(待自板单测)', + foreground='#666666').grid(row=1, column=0, columnspan=7, + sticky='w', pady=(3, 0)) + + # ============ 0x606D 快捷块(整组顺序) ============ + qk2 = ttk.LabelFrame(root, text='0x606D 快捷操作 (RGB 整组顺序, 双色实测=手册naive读法)', + padding=(8, 4)) + qk2.pack(fill='x', padx=8, pady=4) + for i, (code, name) in enumerate((('00', 'BGR'), ('03', 'BRG'), ('04', 'GBR'), + ('05', 'GRB'), ('06', 'RBG'), ('07', 'RGB'))): + ttk.Button(qk2, text='%s %s' % (code, name), width=8, + command=lambda c=code: inj.send('w 60 6D ' + c) + ).grid(row=0, column=i, padx=3) + ttk.Button(qk2, text='读 0x606D', width=10, + command=lambda: inj.send('r 60 6D')).grid(row=0, column=6, padx=3) + ttk.Label(qk2, text='字母串=D[23:16]/D[15:8]/D[7:0] 的分量顺序; 切换实时生效, 不影响HDMI锁定', + foreground='#666666').grid(row=1, column=0, columnspan=7, + sticky='w', pady=(3, 0)) + + # ============ 底部: 日志 ============ + frm = ttk.LabelFrame(root, text='串口日志', padding=(6, 4)) + frm.pack(fill='both', expand=True, padx=8, pady=(4, 8)) + txt_log = tk.Text(frm, height=10, font=('Consolas', 9), state='disabled', + background='#101010', foreground='#C8C8C8') + txt_log.pack(side='left', fill='both', expand=True) + sb = ttk.Scrollbar(frm, orient='vertical', command=txt_log.yview) + txt_log.configure(yscrollcommand=sb.set) + sb.pack(side='right', fill='y') + for tag, color in (('rx', '#C8C8C8'), ('st', '#88AAFF'), ('ok', '#66CC66'), + ('warn', '#E0A030')): + txt_log.tag_configure(tag, foreground=color) + + def poll_log(): + try: + while True: + ln, tag = log_q.get_nowait() + txt_log.configure(state='normal') + txt_log.insert('end', ln + '\n', tag) + if ln.startswith('='): + hot = 'warn' if ('FAIL' in ln or '丢弃' in ln) else 'ok' + txt_log.tag_add(hot, 'end-2l linestart', 'end-2l lineend') + txt_log.see('end') + txt_log.configure(state='disabled') + except queue.Empty: + pass + root.after(80, poll_log) + + refresh_ports() + if default_port: + cbo_port.set(default_port) + root.after(80, poll_log) + root.protocol('WM_DELETE_WINDOW', root.destroy) + root.mainloop() + + +if __name__ == '__main__': + run_gui(sys.argv[1] if len(sys.argv) > 1 else None) diff --git a/I2C-Inject/logos/logo_i2c_orange.ico b/I2C-Inject/logos/logo_i2c_orange.ico new file mode 100644 index 0000000..24cd781 Binary files /dev/null and b/I2C-Inject/logos/logo_i2c_orange.ico differ diff --git a/I2C-Inject/logos/logo_i2c_orange.png b/I2C-Inject/logos/logo_i2c_orange.png new file mode 100644 index 0000000..5d1e980 Binary files /dev/null and b/I2C-Inject/logos/logo_i2c_orange.png differ diff --git a/I2C-Inject/logos/logo_i2c_orange.svg b/I2C-Inject/logos/logo_i2c_orange.svg new file mode 100644 index 0000000..453ee51 --- /dev/null +++ b/I2C-Inject/logos/logo_i2c_orange.svg @@ -0,0 +1,6 @@ + + + + I2C + Inject + diff --git a/I2C-Inject/logos/make_ico_i2c.py b/I2C-Inject/logos/make_ico_i2c.py new file mode 100644 index 0000000..7e4baed --- /dev/null +++ b/I2C-Inject/logos/make_ico_i2c.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +把 logo_i2c_orange.svg 同款设计栅格化为多尺寸 .ico(供 I2C-Inject exe 图标) +================================================================ +设计参数与 logo_ph_orange 同风格: 黑底 512, 橙色圆角矩形 x38 y64 w436 h186 rx34, +框内 "I2C" 146px Arial Bold 黑字(居中), 下方 "Inject" 白字(基线 421)。 +"Inject" 比原版 "Tool" 长, 自动缩放到内容宽度 <= 85% 画布(436px)。 + +输出: logos/logo_i2c_orange.png(512, 预览/复用) + logos/logo_i2c_orange.ico(16~256 七档, exe 图标用) + +用法: python make_ico_i2c.py +""" + +import os + +from PIL import Image, ImageDraw, ImageFont + +BASE = os.path.dirname(os.path.abspath(__file__)) +FONT = r'C:\Windows\Fonts\arialbd.ttf' # Arial Bold, Windows 标配 +MAX_W = 436 # 内容宽度上限(85% 画布) + + +def render(size=512): + s = size / 512.0 + img = Image.new('RGB', (size, size), (0, 0, 0)) + d = ImageDraw.Draw(img) + d.rounded_rectangle([38 * s, 64 * s, (38 + 436) * s, (64 + 186) * s], + radius=34 * s, fill=(255, 153, 0)) + if not os.path.exists(FONT): + raise SystemExit(f'未找到字体 {FONT}, 无法栅格化') + px = max(8, round(146 * s)) + f = ImageFont.truetype(FONT, px) + cx = size / 2 + # I2C: 居中于橙色框(146px 下宽度远小于 436, 无需缩放) + d.text((cx, 64 * s + 186 * s / 2), 'I2C', font=f, fill=(0, 0, 0), anchor='mm') + # Inject: 基线 421 对齐(中心 ~= 368), 超宽则缩字号 + bottom_px = px + while bottom_px > 8: + w = d.textlength('Inject', font=ImageFont.truetype(FONT, bottom_px)) + if w <= MAX_W * s: + break + bottom_px -= 2 + f_bottom = ImageFont.truetype(FONT, bottom_px) + d.text((cx, 368 * s), 'Inject', font=f_bottom, fill=(255, 255, 255), anchor='mm') + return img + + +if __name__ == '__main__': + img = render(512) + png = os.path.join(BASE, 'logo_i2c_orange.png') + ico = os.path.join(BASE, 'logo_i2c_orange.ico') + img.save(png) + img.save(ico, sizes=[(16, 16), (24, 24), (32, 32), (48, 48), + (64, 64), (128, 128), (256, 256)]) + print(f'写出 {png}') + print(f'写出 {ico}') diff --git a/I2C-Inject/rp2040_inject.py b/I2C-Inject/rp2040_inject.py new file mode 100644 index 0000000..ed4f263 --- /dev/null +++ b/I2C-Inject/rp2040_inject.py @@ -0,0 +1,192 @@ +# LT8619C I2C 注入器 — BPI-Pico-RP2040 / MicroPython +# ============================================================ +# 用途: 在 demo 板(LT8619C, 7bit 地址 0x32)的 I2C 总线上择机注入寄存器写入, +# 避开 demo 板 MCU 的 ~500ms 轮询: 等一轮活动结束 + 静置 15ms 后快速 +# "切bank→写→读回校验", 失败自动重试, 完成后立即释放总线回监听。 +# 接线: GP8(物理脚11)=SDA, GP9(物理脚12)=SCL, GND 共地; 不接 VCC, 不加上拉。 +# 串口: USB CDC, 命令均十六进制, 一行一回: +# w 择机写 + 读回校验, 例: w 60 6E 80 (B通道镜像) +# r 择机读, 例: r 60 6E +# s 总线边沿计数统计 +# ? 帮助 +# 返回行以 '=' 开头为正常结果, '!' 为告警/失败。 + +import machine, time, sys, select, array, micropython + +SDA, SCL = 8, 9 +I2C_ADDR = 0x32 +FREQ = 100_000 +QUIET_MS = 50 # 静置判定窗口(总线空闲该时长后才注入) +ARM_TIMEOUT_MS = 1500 # 武装后等下一轮总线活动的超时(超过则硬闯并告警) +RETRY = 3 +CAP_EDGES = 8000 # 嗅探最大边沿数(内存 ~40KB) + +# ---- 嗅探: 紧循环读 GPIO_IN(0xD0000004) 位8/9, 变化即记录时间戳(TIMERAWL, us) ---- +# val 编码: bit0=SDA(GP8), bit1=SCL(GP9) +# 性能: 字节码 machine.mem32 ~12us/读(混叠), viper 裸指针 ~0.27us/读(~3MHz 采样) —— 必须用 viper +def _cap_edges_bc(ts, vv, max_n, timeout_us): + n = 0 + last = (machine.mem32[0xD0000004] >> 8) & 3 + tend = machine.mem32[0x40054028] + timeout_us + while n < max_n: + v = (machine.mem32[0xD0000004] >> 8) & 3 + if v != last: + ts[n] = machine.mem32[0x40054028] + vv[n] = v + n += 1 + last = v + elif machine.mem32[0x40054028] >= tend: + break + return n + + +def _cap_edges_viper(ts, vv, max_n: int, timeout_us: int) -> int: + p_gpio = ptr32(0xD0000004) + p_timer = ptr32(0x40054028) + p_ts = ptr32(ts) + p_vv = ptr8(vv) + last = int(p_gpio[0]) >> 8 & 3 + tend = int(p_timer[0]) + timeout_us + n = 0 + while n < max_n: + v = int(p_gpio[0]) >> 8 & 3 + if v != last: + p_ts[n] = int(p_timer[0]) + p_vv[n] = v + n += 1 + last = v + elif int(p_timer[0]) >= tend: + break + return n + + +_cap_edges = _cap_edges_bc +try: + _cap_edges_viper(array.array('i', bytes(4)), array.array('b', bytes(1)), 0, 0) + _cap_edges = _cap_edges_viper # viper 可用则用(~3MHz 采样) +except Exception: + pass # 退回字节码(仅够解码 <15kHz 的总线) + +last_edge_us = time.ticks_us() +edges = 0 +LED = machine.Pin(25, machine.Pin.OUT) # 板载 LED: 常亮 = 注入器监听中 + + +def _on_edge(_pin): + global last_edge_us, edges + last_edge_us = time.ticks_us() + edges += 1 + + +def bus_listen(): + """两脚浮空输入 + 双沿中断(只听不驱)""" + for p in (SDA, SCL): + pin = machine.Pin(p, machine.Pin.IN, None) + pin.irq(trigger=machine.Pin.IRQ_RISING | machine.Pin.IRQ_FALLING, + handler=_on_edge) + + +def wait_window(): + """等一轮总线活动结束再静置 QUIET_MS; 返回 True=正常等到, False=超时硬闯""" + saw = edges + t0 = time.ticks_ms() + while edges == saw: # 等下一轮活动出现(demo ~500ms 一轮) + if time.ticks_diff(time.ticks_ms(), t0) > ARM_TIMEOUT_MS: + print('! 无总线活动 %.0fms, 直接注入' % ARM_TIMEOUT_MS) + return False + time.sleep_ms(2) + t0 = time.ticks_ms() + while True: # 等静置 + if time.ticks_diff(time.ticks_us(), last_edge_us) >= QUIET_MS * 1000: + return True + if time.ticks_diff(time.ticks_ms(), t0) > 2000: + print('! 静置超时, 直接注入') + return False + time.sleep_us(200) + + +def inject(bank, reg, val=None): + """注入并校验; val=None 只读。返回 (ok, readback)""" + for attempt in range(1, RETRY + 1): + wait_window() + i2c = machine.I2C(0, sda=machine.Pin(SDA), scl=machine.Pin(SCL), freq=FREQ) + try: + if val is not None: + i2c.writeto(I2C_ADDR, bytes([0xFF, bank])) # 切 bank + i2c.writeto(I2C_ADDR, bytes([reg, val])) # 写寄存器 + rb = i2c.readfrom_mem(I2C_ADDR, reg, 1)[0] # repeated-start 读回 + except OSError as e: + print('! 第%d次总线错误 %s' % (attempt, e)) + rb = None + finally: + bus_listen() # 立即释放总线 + if rb is None: + continue + if val is None or rb == val: + return True, rb + print('! 第%d次校验不符: 写%02X 读%02X' % (attempt, val, rb)) + return False, None + + +def main(): + bus_listen() + for _ in range(3): # 上电自检: 快闪 3 次 + LED.on(); time.sleep_ms(80); LED.off(); time.sleep_ms(80) + LED.on() # 常亮 = 监听中 + print('LT8619C 注入器就绪 GP8=SDA GP9=SCL %dkHz addr 0x%02X' % (FREQ // 1000, I2C_ADDR)) + poll = select.poll() + poll.register(sys.stdin, select.POLLIN) + buf = b'' + while True: + try: + if poll.poll(50): + buf += sys.stdin.read(1) + if buf[-1:] not in (b'\n', b'\r'): + continue + line = buf.strip().decode() + buf = b'' + parts = line.split() + if not parts: + continue + cmd = parts[0].lower() + if cmd == 'w' and len(parts) == 4: + bank, reg, val = (int(x, 16) for x in parts[1:]) + ok, rb = inject(bank, reg, val) + print('= w %02X %02X %02X : %s 读回=%s' % + (bank, reg, val, 'OK' if ok else 'FAIL', + '%02X' % rb if rb is not None else '--')) + elif cmd == 'r' and len(parts) == 3: + bank, reg = (int(x, 16) for x in parts[1:]) + ok, rb = inject(bank, reg) + print('= r %02X %02X : %s %s' % + (bank, reg, 'OK' if ok else 'FAIL', + '%02X' % rb if rb is not None else '--')) + elif cmd == 's': + print('= 边沿计数 %d' % edges) + elif cmd == 'c': + ms = int(parts[1]) if len(parts) > 1 else 700 + for p in (SDA, SCL): # 捕获期间暂停监听中断 + machine.Pin(p, machine.Pin.IN, None).irq(handler=None) + ts = array.array('i', bytes(4 * CAP_EDGES)) + vv = array.array('b', bytes(CAP_EDGES)) + n = _cap_edges(ts, vv, CAP_EDGES, ms * 1000) + bus_listen() + print('= capture %d edges / %d ms' % (n, ms)) + t0 = ts[0] if n else 0 + out = [] + for i in range(n): + out.append('%d %d' % (ts[i] - t0, vv[i])) + if len(out) >= 100: + print('C ' + '\nC '.join(out)) + out = [] + if out: + print('C ' + '\nC '.join(out)) + print('C.') + else: + print('? w bank reg val | r bank reg | s | c [ms]') + except KeyboardInterrupt: + break + print('退出') + + +main() diff --git a/README.md b/README.md index 9d2a91a..c436b3c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,35 @@ # LT8619C_Debug_Tools -本仓库用存储LT8619C调试过程中需要的一些小工具(包括AI生成) \ No newline at end of file +LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套上位机工具集。 +上游主仓库(固件):[LT8619C_DLPC3421_HDMI](https://gitea.hulk.wang/hulk/LT8619C_DLPC3421_HDMI)。 + +## 工具一览 + +| 工具 | 用途 | 快速开始 | +|---|---|---| +| [HDMI-Tool](HDMI-Tool/) | HDMI 源端:EDID 查看/解算、分辨率切换、纯色与测试图案全屏投屏(R/G/B 纯色值按钮用于位交换类测试) | 双击 `HDMI-Tool.exe`;详见 [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) | + +## 环境要求 + +- 打包版 exe 开箱即用(Windows 10/11 x64,无需安装 Python) +- 源码运行:Python ≥3.10;HDMI-Tool 为纯标准库(自定义图片 JPG/BMP 需 Pillow); + I2C-Inject 需 `py -m pip install pyserial` +- 重新打包:各工具目录内 `python build_exe.py`(自动安装 PyInstaller) + +## 目录结构 + +``` +LT8619C_Debug_Tools/ +├── HDMI-Tool/ # 源码 + 打包 exe + 测试图资产 + 图标 + README +├── I2C-Inject/ # PC 端 GUI + RP2040 固件 + 打包 exe + 图标 + README +├── LICENSE +└── README.md +``` + +## 备注 + +- 源码与打包产物均入库:exe 改动跟随源码提交更新 +- 两件工具的寄存器语义(0x606E 位交换 / 0x606D 整组顺序)均经 demo 板逻辑分析仪 + 双色交叉实测,过程数据与规程存于上游主仓库 `doc/architecture/` +- 更新:2026-09-03