58 Commits
Author SHA1 Message Date
hulk 4149d203b0 fix: v0.4.1 修复主题切换丢 swatch 底色与连接失败长错误串撑爆布局;新增 selftest_theme;重打包 2026-09-28 15:20:11 +08:00
hulk 264a0c74a6 docs(readme): 工具一览表增加各工具图标列;更新日期戳 2026-09-28 11:00:18 +08:00
hulk 190cf29aa5 chore: 仓库更名 LT8619C_Debug_Tools → HW-Debug-Tools
- Gitea 仓库已改名(旧地址自动重定向),git remote 同步更新
2026-09-28 10:01:16 +08:00
hulk d658fd459a feat: 图标改为家族版式绿色款(同 FlashTool 色系)并重打包
- 黑底/绿色圆角框 rgb(0,200,120)/框内黑字 Serial + 白字 Mate, 与 DLPilot/EDID-Viewer/FlashTool 同版式
2026-09-28 09:37:54 +08:00
hulk b627f79dad docs(agent): 记录 SerialMate v0.1.0 已推送并发布 Release 2026-09-27 18:58:44 +08:00
hulk c26d2cdbaa feat: 新增 SerialMate 串口调试助手 v0.1.0
- 协议帧构成令牌序列可定制(包头/板ID/LEN口径/5种CRC x 3种覆盖), 默认兼容 DLPilot 光机协议
2026-09-27 18:52:54 +08:00
hulk 372cb6b7a0 docs: 关闭 0x606E 0x40/0x80 位交换实测待办(用户确认现状符合预期);M305 自测向量降为低优先 2026-09-25 02:12:25 +08:00
hulk 68fb81fb13 docs(agent): 记录画面镜像新需求 —— DLPC3421 14h 原生支持横/纵独立翻转,含 39h 命名陷阱警示 2026-09-25 02:08:44 +08:00
hulk 733847a58c docs: agent 文档体系按用户级默认工作约定重整为开发需求与进度管理 2026-09-25 01:48:18 +08:00
hulk f594f60fdd docs(I2C-Inject): 标记暂停开发 —— 代码与固件保留备查,根 README 同步状态 2026-09-25 01:41:43 +08:00
hulk e92fa9f349 docs: 根 README 补充 EDID-Viewer(工具一览/环境/目录/发布流程) 2026-09-25 01:40:17 +08:00
hulk fe4bca5a33 feat(EDID-Viewer): v0.1.0 入库 — Fluent EDID 查看器,目录对齐 DLPilot
- src/ 人写代码:main.py(Fluent 界面、Light/Dark/dark1/dark2/跟随系统主题、
  关于按钮;主题三件套 = setTheme + QPalette/应用级 QSS + DWM 标题栏,
  修复 Light 下窗口背景/页签/标题栏仍为系统深色的问题)、edid.py
  (EDID/CTA/HDMI/HDR 解析)、windows_edid.py(WMI 只读)、theme.py、
  test_edid.py(9 项解析测试,含随机畸形数据无崩溃检查)
- assets/ 图标与生成脚本;release/ 交付:EDID-Viewer.exe(onefile 63.7MB,
  启动/图标/主题已验证)+ README,与 DLPilot/release 同约定 exe 直接入库
- 流水线对齐 DLPilot:src/build_exe.py(spec 由脚本生成不入库,剔除 PATH 上
  Poppler/Conda 版本化 ICU,避免 QtCore 报 WinError 127)→
  package_release.py(组装 EDID-Viewer-v*-win64.zip,zip 只上传 Gitea)→
  publish_gitea.py(token 走 GITEA_TOKEN,自签证书 --insecure)
- agent.md 更名 AGENTS.md(DLPilot 双文档约定:README 给人 / AGENTS.md 给
  Agent);根 .gitignore 增加 EDID-viewer/release/*.zip
- validation/ 保留 EDID 本机/合成样本与窗口截图、关于对话框自检脚本
2026-09-24 22:34:02 +08:00
hulk b58cdc42b6 feat(DLPilot): v0.4.0 — PySide6-Fluent-Widgets UI and Light theme 2026-09-24 19:09:24 +08:00
hulk 21c1b7bcc1 落盘agent.md文件 2026-09-20 00:26:02 +08:00
hulk 144730fb3a fix(DLPilot): 二进制载荷日志行以 hex 展示 —— 修复复制被 NUL 截断 2026-09-18 15:32:34 +08:00
hulk ca898be299 chore(DLPilot): v0.3.6 打包(M305 功率检测三步序列 + 功率 W 两位小数显示) 2026-09-18 15:16:42 +08:00
hulk 511e7dfa44 feat(DLPilot): M305 实测负载功率对接(功率检测三步序列) 2026-09-18 15:10:14 +08:00
hulk 6d1e2c6f8d feat(DLPilot): v0.3.5 — 电源读数框等长自适应 + 最大化卡顿治理 2026-09-18 01:23:45 +08:00
hulk a0d64c322a fix(DLPilot): 修复电源自检卡死 —— 帧路由引用未定义变量 payload 2026-09-18 01:17:08 +08:00
hulk a666ed7c43 fix(DLPilot): 单实例锁 + 串口占用提示 —— 修复'断不开'的实际根源 2026-09-18 01:13:05 +08:00
hulk 1f68665860 fix(DLPilot): 串口卡死与序列互相干扰修复 2026-09-18 01:08:51 +08:00
hulk 668a7e1ca5 feat(DLPilot): 功率检测读数暂不计算功率值(待固件功率命令返回), 占位显示 2026-09-18 01:04:27 +08:00
hulk bf9d70722c feat(DLPilot): 电源自检补全 FPDO 挡位判定; 功率检测实现 2026-09-18 00:58:42 +08:00
hulk 5d06e69a41 feat(DLPilot): M303/M304 SQ52205 电源实测协议对接(规则先行, 按钮后续排版) 2026-09-18 00:41:09 +08:00
hulk a3430d15e0 fix(DLPilot): 电源自检误把成功帧判为错误(实机 M301 回包 payload='PD' 无 ok 前缀, 改按 TYPE 译码分类) 2026-09-17 11:16:11 +08:00
hulk 419c902a38 chore: gitignore 源码模式运行时配置 src/pattern_slots.json 2026-09-17 10:46:09 +08:00
hulk b1bb320b50 feat(DLPilot): 新增电源检查卡(PD 自检序列 M301/M302 状态位 + FPDO, 功率检测占位); 窗口加高; 版本 0.3.4 打包 2026-09-17 10:43:31 +08:00
hulk 2d1d8f83b1 chore(DLPilot): v0.3.3 打包(修复 USB 拔出后重连失效) 2026-09-17 10:17:56 +08:00
hulk d991d57447 fix(DLPilot): 串口设备拔出后同参数重连无响应(掉线分支同步清 _open_key); 版本 0.3.3 2026-09-17 10:17:52 +08:00
hulk 43d78de045 chore: 移除临时诊断脚本 2026-09-14 15:44:27 +08:00
hulk 739fdeedf3 chore(DLPilot): v0.3.2 打包(exe 含标题栏版本号, 停止测试图像更名) 2026-09-14 15:44:04 +08:00
hulk b7eda91ca7 feat(DLPilot): 标题栏显示版本号(与关于弹窗同源 APP_VERSION) 2026-09-14 15:37:10 +08:00
hulk 763d80534c feat(DLPilot): '停止投屏'更名'停止测试图像'; 版本 0.3.2; 清理残余 HDMI-Tool 引用 2026-09-14 15:30:58 +08:00
hulk 31fcd93607 chore: 文件夹更名 HDMI-Tool -> DLPilot(路径与文档此前已对齐) 2026-09-14 15:25:05 +08:00
hulk df6a5f4343 fix(DLPilot): publish_gitea 请求自动重试(实例 TLS 间歇性 SSL EOF) 2026-09-14 15:12:16 +08:00
hulk 32d0d26c5a feat(DLPilot): '恢复桌面'更名'停止投屏'; 版本 0.3.1 2026-09-14 15:02:54 +08:00
hulk afb6513e56 fix(DLPilot): 投屏退出体验 — 替换语义 + 一键恢复桌面 2026-09-14 14:55:30 +08:00
hulk 584fd32ebe merge: DLPilot v0.3.0 — CustomTkinter 双栏界面重构 + 光机串口参数化 + TEC 控制 2026-09-12 03:45:46 +08:00
hulk b12bc584b8 feat(DLPilot): 图标更新为 DLP/Pilot 双行设计; 图案栏位两排各 5 个等宽 2026-09-12 03:28:52 +08:00
hulk 13dab778c6 style(DLPilot): 关于弹窗补作者/硬件正式命名; 卡片标题行垂直居中 2026-09-12 03:21:20 +08:00
hulk 607767ce19 feat(DLPilot): 关于按钮 + 外观默认 System + 校准图案行压缩 2026-09-12 03:15:07 +08:00
hulk 210e901ede feat: 产品更名 DLPilot(3D 光固化光机调试台) + 按标记调整界面 2026-09-12 03:05:32 +08:00
hulk 9a70efc6de fix(HDMI-Tool): 下拉菜单配色统一为深色主题 2026-09-12 02:36:46 +08:00
hulk 19afeed294 style(HDMI-Tool): 按用户标记统一左列高度与温度实时显示 2026-09-12 02:19:51 +08:00
hulk cca7cbc962 feat(HDMI-Tool): 光机串口参数可选(默认8-N-1) + TEC 控制与温度读取 2026-09-12 02:07:26 +08:00
hulk aff31656ed style(HDMI-Tool): 串口下拉贯通卡片宽度; 参数行补全 8-N-1(板卡固件固定标注) 2026-09-12 01:50:15 +08:00
hulk bb8efa7392 style(HDMI-Tool): 按用户标记微调 CTk 界面 2026-09-12 01:42:01 +08:00
hulk 7051aa1774 feat(HDMI-Tool): CustomTkinter 双栏仪表盘界面重构(feature/ctk-ui) 2026-09-12 01:30:50 +08:00
hulk 222d452f63 feat(HDMI-Tool): 光机串口下拉显示设备描述, CH344 多路口可辨 2026-09-11 00:46:03 +08:00
hulk 0a7b1f576a chore(HDMI-Tool): package_release 平铺复制图案/说明到 release/ — 与 zip 内容一致, 文件夹开箱即用 2026-09-11 00:40:48 +08:00
hulk 4520ca970d fix(HDMI-Tool): 启动拓扑改智能判定 — 仅转接板(XLS)未激活时强制扩展, 家里仅第二屏零打扰 2026-09-11 00:37:48 +08:00
hulk cdef23b80a feat(HDMI-Tool): v0.2.0 集成光机控制(RS485→GD32网关→DLPC3421) — 本地提交, 待验证后再推送发布 2026-09-11 00:25:28 +08:00
hulk 0ca7ea8675 docs(HDMI-Tool): AGENTS.md 记录本机已持久化 GITEA_TOKEN 用户级环境变量 2026-09-10 23:55:10 +08:00
hulk a39a4a665a fix(HDMI-Tool): publish_gitea 兼容 assets 纯数组返回; 文档补记本实例需 --insecure 2026-09-10 23:51:39 +08:00
hulk 7ab8264752 refactor(HDMI-Tool): 划分 src/assets/release 三区目录, 补齐人读/AI 读双文档与发布流水线 2026-09-10 23:44:56 +08:00
hulk 1f7e697558 feat: HDMI-Tool 启动时强制扩展显示拓扑 + 收录波形/注入脚本 2026-09-10 19:47:39 +08:00
hulk 65c85c2844 feat(I2C-Inject): 归档 RP2040 固件到 mcu-rp2040 并补齐说明 2026-09-03 17:33:17 +08:00
hulk c38f12b230 feat: HDMI-Tool 与 I2C-Inject 首版工具集 2026-09-03 17:24:33 +08:00
115 changed files with 13049 additions and 2 deletions
+26
View File
@@ -0,0 +1,26 @@
# Python
__pycache__/
*.pyc
# PyInstaller 工作目录(打包产物 exe 已直接入库, 构建中间物不入库)
build/
dist/
*.spec
# 发布 zip: 可由 src/package_release.py 重新生成, 只上传 Gitea Release
DLPilot/release/*.zip
EDID-viewer/release/*.zip
SerialMate/release/*.zip
# 源码模式运行时的配置写入(config.BASE_DIR=src/), 与 release/ 下入库的预置栏位区分
DLPilot/src/pattern_slots.json
# SerialMate 无预置栏位, 运行态配置(最近串口/通道等)一律不入库
SerialMate/src/serial_mate.json
SerialMate/**/serial_assistant.json
SerialMate/release/serial_mate.json
# 板型档案(board_profiles.json)是用户本机数据, 首次运行自动生成默认值
SerialMate/src/board_profiles.json
SerialMate/release/board_profiles.json
# ZCode 客户端本地会话数据(计划留档等), 非项目配置
.zcode/
+217
View File
@@ -0,0 +1,217 @@
# AGENTS.md — DLPilot 工程档案(开发约束与进度)
> 人类文档 [README.md](README.md) 回答"这是什么、怎么用";本文记录"代码怎么组织、
> 怎么构建发布、改动时哪些约束不能破坏",兼作本工具的进度记录。
> 修改本目录代码前先读完本文。仓库整体的开发需求与进度管理见根目录 `agent.md`。
## 1. 项目定位
仓库 `HW-Debug-Tools` 是 LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板的**调试上位机工具集**,
上游主仓库(固件)为 `hulk/LT8619C_DLPC3421_HDMI`(同 Gitea 实例 `gitea.hulk.wang`),
上游固件本机路径 `E:\Hulk-Coding\LT8619C_DLPC3421_HDMI`;工具仓库路径 `E:\Hulk-Coding\HW-Debug-Tools`。
DLPilot 工作在两端:HDMI **源端**(PC 显卡侧,EDID/分辨率/图案)与**光机控制端**
(RS485 → 板载 GD32E230 网关 → DLPC3421)。寄存器语义判读基线(0x606E 位交换 /
0x606D 整组顺序)与光机协议规程都在上游仓库 `doc/architecture/` 与 `protocols.md`,不在本仓库。
## 2. 目录契约(重要)
```
DLPilot/
├── src/ 源代码区 —— 只有这里可以手改(logos/ 改图标源后重跑 make_ico.py)
├── assets/ 生成资产区 —— gen_*.py 的产物(PNG 图案),可随时再生成,改动需提交
├── release/ 产物区 —— 机器生成,禁止手改:DLPilot.exe、pattern_slots.json、
│ bw/solid 图案(package_release.py 从 assets/ 平铺复制来,均入库)、
│ *.zip(gitignore,只上传 Release)。release/ 本身=完整可直接运行的
│ 发布文件夹(exe+附属平铺),内容与 zip 一致
├── README.md 人类文档
└── AGENTS.md 本文
```
划分原则:**"人写的"在 src/,"生成的"按去向分 assets/(随包分发)与 release/(直接交付)**。
新文件必须落对区域;不要在 src/ 外新建源码,不要手改 assets/ 下的 PNG(改生成脚本再跑)。
### 文件角色速查
| 文件 | 角色 |
|---|---|
| `src/hdmi_gui.py` | 入口: argparse 命令行分发(--dump/--edid-fields/--test-pattern/--dlpc-cmd/--seconds), GUI 委托 app_fluent |
| `src/app_fluent.py` | PySide6-Fluent-Widgets 主界面: 双栏仪表盘(左 HDMI 源端五卡/右 光机五卡), 光强/定时快捷滑条 |
| `src/display_win.py` | Win32 显示器枚举/分辨率切换/智能拓扑(纯 ctypes) |
| `src/edid_parse.py` | EDID 1.4/CEA-861 解析(纯标准库, 上游 lt8619c_edid.c 以此为参考解码器) |
| `src/patterns.py` | 测试图案全屏投屏窗口(tkinter, ESC 退出, 测试卡区域 1:1 居中) |
| `src/config.py` | 运行目录定位(BASE_DIR)与 pattern_slots.json 读写(slots/area/serial 三键) |
| `src/dlpc_rs485.py` | 光机 RS485 协议模块: 帧构造/增量解析/译码表/预设命令/串口线程(DLPC485) |
| `src/selftest_rs485.py` | 光机协议自检(对照上游实测帧向量, 无硬件回归, 改协议代码后必跑) |
| `src/build_exe.py` | PyInstaller 打包 → `release/DLPilot.exe`(onefile/noconsole, 内嵌图标+pyserial+PySide6-Fluent-Widgets) |
| `src/package_release.py` | 复制图案到 release/(平铺) → `release/DLPilot-v<ver>-win64.zip`(校验栏位引用完整性; release/ 与 zip 内容一致) |
| `src/publish_gitea.py` | 建/复用 Gitea Release 并上传 zip(tag 默认取 zip 文件名) |
| `src/hdmi_source.ps1/.bat` | 独立 PowerShell 命令行工具(自包含,与 GUI 无代码共享) |
| `src/gen_bw_patterns.py` | 生成 `assets/bw_*.png` ×5(640x360 黑白,需 Pillow) |
| `src/gen_solid_colors.py` | 生成 `assets/solid_*.png` ×10(800x600 纯色,需 Pillow) |
| `src/la_wave_analyzer*.py` | 逻辑分析仪 CSV 波形判读(2/3 通道,40MHz 像素时钟假设) |
| `src/logos/make_ico.py` | svg/png → ico(写在其自身目录) |
| `release/pattern_slots.json` | 预置栏位:西门子星/波带片/综合测试卡,area=640x360;`serial` 键记忆光机串口 |
| `UI参考布局.svg/.png` | 界面布局设计稿(app_fluent.py 双栏布局的规格来源, 随分支入库) |
## 3. 代码不变量(改 hdmi_gui.py 必须维持)
1. **依赖分层**:CLI/显示/EDID/协议/配置模块(display_win/edid_parse/config/
dlpc_rs485)纯标准库;PySide6-Fluent-Widgets 是 GUI(app_fluent)的**硬依赖**(装了
才能开界面,CLI 不受影响),GUI 图案窗口使用 Qt,CLI 图案窗口由 patterns.py 使用 tkinter 创建。可选依赖
仍走"try-import + HAS_* 标志 + 缺失降级"模式:Pillow、pyserial——缺失时对应功能
禁用/提示安装,其余功能必须照常工作。
2. **模块边界**:hdmi_gui.py 只是入口, 不放实现; GUI 的显示器/EDID 访问走 display_win/edid_parse;ctypes 仅用于
单实例锁、Windows 标题栏与 frozen DLL 加载; pyserial 只准在 dlpc_rs485.py 里 import
(hdmi_gui/app_fluent 经 `dlpc_rs485.HAS_SERIAL` 与 `DLPC485` 类间接使用)。
3. **BASE_DIR 语义**(定义在 config.py):frozen(PyInstaller onefile)时
`BASE_DIR=exe 所在目录`(`sys.executable`),源码运行时 `=config.py 所在目录`
(即 src/)。所有用户数据(pattern_slots.json、自定义图片)都写/读自 BASE_DIR
**平铺**存放,不建子目录。
4. **自定义栏位 `file` 字段是裸文件名**:加载时 `os.path.join(BASE_DIR, file)` 解析,
因此 pattern_slots.json 引用的图片必须与 exe 同目录 —— package_release.py 正是按此平铺打包。
5. **exe 自包含**:图标经 `--add-data` 内嵌(运行时从 `_MEIPASS` 读);内置五种图案
(红/绿/蓝/棋盘格/网格线)由程序绘制,运行时**不依赖** assets/;solid/bw PNG 只服务自定义栏位。
6. **DPI 感知**:进程启动即 `SetProcessDpiAwareness(2)`,图案窗口按物理像素定位,勿移除。
7. **智能显示拓扑(勿改回无条件扩展)**:启动时枚举"接入但不在桌面"的显示器,仅当其
EDID 厂商字节为转接板签名 `61 93`(压缩码 "XLS",上游 lt8619c_edid.c)时才
`SetDisplayConfig(SDC_TOPOLOGY_EXTEND|SDC_APPLY)` 拉回桌面,静默容错。动机:板卡
热插拔后 Windows 可能停留在"仅电脑屏幕"导致枚举不到板卡。用户主动选择的拓扑
(家里的"仅第二屏幕"等,未激活的是笔记本内屏,厂商非 XLS)一律不动。
8. **分辨率切换是临时的**:走 `CHANGEDISPLAYSETTINGS`(不写注册表),"恢复"用会话开始时快照。
9. **EDID 来源是注册表**(`HKLM\SYSTEM\CurrentControlSet\Enum\DISPLAY\...\Device Parameters\EDID`),
不是 WMI/DDC;exe 的 noconsole 版无 stdout,`--dump/--edid-fields` 只在源码/控制台版可用。
10. **光机串口线程回调不直接碰 GUI**:DLPC485 的 on_event/on_status 在后台线程触发,
必须经 `queue.Queue` + Qt 定时器轮询投递回主线程(现行 `_poll_dlpc` 模式,
与 I2C-Inject 的 Injector 同源);退出路径(关窗与 `--seconds` 自动关)都会走到
`App.closeEvent()`,在那里 `dlpc.close()` 收线程。
11. **pattern_slots.json 格式**:`{'area', 'slots', 'serial'}` 三键共存;load 兼容旧版
纯数组(无 area)与无 serial 键两种历史格式,save 永远写全三键。`serial` 子键为
`{port, baud, bytesize(8/7/6/5), parity(N/E/O), stopbits(1/2)}`,旧配置缺子键按
默认 8-N-1 补齐。
12. **GUI = app_fluent.py 双栏仪表盘**(规格:`DLPilot/UI参考布局.svg`):左列 HDMI 源端
五卡(显示器/模式/EDID 信息/测试图案/hex),右列光机五卡(串口/数据/电源/命令/滑条),
支持的模式和 EDID 信息默认折叠;顶栏 Dark/Light/System 切换。Light/Dark 同时更新
Fluent 主题、Qt 调色板、容器样式与 Windows DWM 标题栏,勿只调用 setTheme。
**fluent 控件的自定义样式禁止直接 setStyleSheet**:setTheme 会用库自带 qss 重刷全部
已注册控件,直写样式随之丢失(v0.4.0 swatch 丢底色的根因)。须走
`setCustomStyleSheet(控件, lightQss, darkQss)`(qss 需覆盖 hover/pressed/disabled 态,
否则悬停瞬间被自带态样式盖回);仅 PushButton 等无 themeChanged 钩子的控件可幸存。
FluentLabelBase 系(StrongBodyLabel 等)例外:其 setTextColor 在每次切主题时整体重写
自定义槽,任何 setCustomStyleSheet 都会被冲掉——字号等改用 QFont(自带 label.qss 无
font-size 规则,QFont 不会被冲)。RGB swatch 底色回归测试见 selftest_theme.py。
PD 协议成功显示绿色,FPDO 仍独立判定。打包必须收集 `qfluentwidgets` 资源,并加入 Windows 系统 `icuuc.dll`,否则 QtCore 可能被其它 ICU DLL 解析失败。
## 4. 领域语义(测试值都有含义,勿"顺手改")
- 纯色值 55/AA/FF:55(01010101) 与 AA(10101010) 互为字节镜像对(检出位序颠倒/位交换),
FF(11111111) 全高基准;R/G/B 三行对应 RGB888 三字节组(R=D[23:16]/G=D[15:8]/B=D[7:0])。
- 800x600@60:LT8619C **demo 板** shadow EDID 首选时序(solid 系列分辨率与之一致)。
- 640x360:测试卡区域默认值(16:9 半分辨率,点对点检查用);full=铺满所选屏。
**同时是转接板光机 DLPC3421 的 nHD 面板原生分辨率**(该板 shadow EDID 只声明 640x360@60),
投 640x360 图案 + RS485 开光机 = 整链路 1:1 验证。
- bw 系列图案各自目的见 `src/gen_bw_patterns.py` 顶部清单注释。
- 光机 RS485 命令集(M730/M731/M732/M201/M202/M737/M999/M888/M7/M8/M1/**M303/M304**)语义、
帧格式 `D5 01 LEN <ASCII> CRC`(累加和校验)、错误码与**就绪门禁**
(DLPC 未就绪时 M730/M731/M732 被拒回 0xFF)以上游 `protocols.md` 为唯一权威,
本仓不维护协议文档副本;注意 **M7S0 是"开启"TEC 自动控温**(方向反直觉)、
M737 在定时投光中延迟应答(CLI 等待上限 35s)。
- **M303/M304/M305 载荷是二进制例外**(SQ52205 电源实测):成功载荷为大端原始值,
**不是 ASCII**——Frame.payload 解出来是乱码,必须走 `payload_bytes()` 取原始字节;
M303=u16 mV(12V 轨窗口 11400~12600),M304=s16 补码 mA(正=正向,固件原样上总线,
上位机按有符号解析,FF 06=−250),**M305=u32 mW(LEN=4,勿复用双字节解析;
恒非负,量程 0~295000,能量方向看 M304 符号;显示 mW→W 两位小数)**。
封装在 `payload_u16_be/payload_s16_be/payload_u32_be/format_power`,
回归向量在 selftest_rs485.py(M303/M304 真机帧,M305 算法推演帧)。
- **电源自检序列**(M301 协议→M302 挡位最大电流→M303 实测母线电压,帧驱动状态机
`_pd_on_frame/_pd_seq`,超时 3s/步):FPDO 判定 = 电压就近挡(标准 PD 固定挡
5/9/12/15/20/28V,±5% 容差)+ M302 挡位电流;**12V 且 ≥3A 绿底,挡位不符红底,
无匹配挡 ERR**。应答按 TYPE=0xF0 判成功;
CH224Q 电压配置寄存器(0x0A)只写不可读,挡位只能实测判定。
- **功率检测** = M303+M304+M305 序列 → 读数框 电压V / 电流mA / 功率W(M305 u32 mW
换算两位小数);各步载荷长度按 kind 校验(2/2/4 字节),坏帧中止显示 ERR。
## 5. 构建 / 发布流水线(全部在 src/ 下执行)
```powershell
python gen_bw_patterns.py ; python gen_solid_colors.py # 仅当改了图案生成逻辑
python build_exe.py # → ../release/DLPilot.exe(缺 PyInstaller 会自动 pip install)
python build_exe.py --debug # 附加带控制台的 DLPilot-console.exe(调试 --dump 用)
python package_release.py <版本> # → ../release/DLPilot-v<版本>-win64.zip
python publish_gitea.py ../release/DLPilot-v<版本>-win64.zip --replace --insecure # 上传(需 GITEA_TOKEN)
```
发布约定:
- tag/release 命名 `DLPilot-vX.Y.Z`,zip 命名 `DLPilot-vX.Y.Z-win64.zip`(publish_gitea.py
依赖此命名从文件名推 tag);
- **exe、pattern_slots.json 与 release/ 下平铺的图案 png 改动跟随源码/资产生成提交入库;
zip 永不入库**(.gitignore 已挡);
- publish_gitea.py 幂等:release 已存在则复用,同名资产默认跳过,`--replace` 删旧重传;
- token 读环境变量 `GITEA_TOKEN`:本机已用 setx 持久化为用户级变量(新开的会话/终端自动
继承,值不入库;在 setx 之前启动的进程读不到,需重开终端),急用时也可临时传 `--token`;
- Gitea 实例 `gitea.hulk.wang`,API 走 https(443),git 走 ssh(1234)——从 origin 远程自动解析;
python urllib 连该实例会在部分请求上报 `SSL: UNEXPECTED_EOF`,**实际发布需加 `--insecure`**;
- 本实例 Gitea 的 API 兼容性怪癖:`GET /releases/tags/{tag}` 返回的 `assets` 是纯数组,
而 `GET /releases/{id}` 是 `{items:[...]}` —— publish_gitea.py 已按两种形状兼容,勿"简化"回去。
## 6. 已知坑(历史踩过,勿重蹈)
- onefile 运行时 `__file__` 指向临时解压目录 `_MEIPASS`,定位 exe 必须用 `sys.executable`(§3.3);
- 重新打包前旧 exe 可能被残留进程占用 → build_exe.py 会 taskkill 同名进程,手改打包脚本时保留;
- 打包后本机图标缓存不刷新:`ie4uinit -show`,不是产物问题;
- 改图标流程:改 svg → `make_ico.py` 生成 ico → 重新打包(ico 同时是文件图标与运行时窗口图标);
- GUI 与 hdmi_source.ps1 各自独立实现显示器枚举,行为可能微差(GUI 与 `-List` 序号一致,
但与"设置"面板序号无保证);多屏操作前先确认序号;
- `pattern_slots.json` 兼容两种历史格式,写出只写新格式(详见 §3.11);
- 旧版 `app_gui.py` 仅保留作历史对照,当前入口使用 `app_fluent.py`;新增或修改界面时
应遵循 PySide6-Fluent-Widgets 的控件和 Qt 布局,不要把旧版 CustomTkinter 依赖带回入口。
## 7. 验证清单(改完代码后)
1. `python -m compileall -q src`(在 DLPilot/ 下) —— 全模块可编译;
2. `python selftest_rs485.py` —— 光机协议回归(改 dlpc_rs485.py 后必跑,向量来自上游 protocols.md);
3. `python selftest_theme.py` —— 主题切换回归(改 app_fluent.py 涉及样式/主题后必跑;
离屏渲染,Dark/Light 往返后校验 swatch 底色样式与渲染像素、标题字号);
4. `python hdmi_gui.py --seconds 5` —— GUI 冒烟(自动开关);
5. `python hdmi_gui.py --test-pattern Checker --area 800x600 --seconds 3` —— 投图链路;
6. `python hdmi_gui.py --dump` —— EDID 解算不抛异常;
7. `python hdmi_gui.py --dlpc-cmd M999`(不带 --port)—— 应明确报错而非崩溃(无硬件时的优雅降级);
8. `python build_exe.py` → 双击 release/DLPilot.exe 确认图标/双栏布局/投图/光机区渲染;
9. `python package_release.py <版本>` —— 不报"引用图片缺失"即栏位完整性 OK。
## 8. v0.4.0 进度记录(2026-09-24)
- 入口已切换 `app_fluent.py`;历史 `app_gui.py` 保留,不改其历史版本号。
- 本次验证:源码编译通过、RS485 自测 ALL PASS、Qt 离屏浅色渲染、正式 EXE 定时退出码 0。
原生桌面自动化不可用,未完成成品 Light 截图;未将硬件功能标记为本次实机验证通过。
- 发布已将本次源码、文档、EXE 纳入同一个提交(`74b64dc`),远端标签 `DLPilot-v0.4.0`
指向该提交、附件已核对;zip 不入库。
- `release/pattern_slots.json` 的本机串口记忆改动属于运行态配置,勿随提交悄悄带入;
发布使用仓库预置配置,临时打包后恢复本机文件。
- 关联动态:EDID-Viewer 已于 2026-09-25 从本工具的"源端 EDID 查看"需求独立成工具
入库并发布 v0.1.0,与本目录无代码共享;I2C-Inject 已标记暂停开发(详见根 `agent.md`)。
## 9. v0.4.0 后修复记录
- 2026-09-28 修复"切换 Light 后 R/G/B hex 按钮底色丢失、切回 Dark 也不恢复":根因是
fluent 控件直接 `setStyleSheet` 的自定义样式在 `setTheme` 时被库重刷的自带 qss 整体
覆盖(任何方向的切换都会丢,非 Light 特有)。swatch 改走 `setCustomStyleSheet`(qss
覆盖 hover/pressed/disabled 态),同病的顶栏标题 24px 字号改用 QFont(FluentLabelBase
的自定义槽被库的 setTextColor 每次切主题重写,见 §3.12)。新增 `src/selftest_theme.py`
离屏回归(样式表存活 + 渲染像素比对),本次验证:compileall / selftest_rs485 /
selftest_theme / GUI 冒烟全部通过。
- 2026-09-28 以 v0.4.1 重新打包:APP_VERSION → 0.4.1,`build_exe.py` 生成新
release/DLPilot.exe(67.1 MB,exe `--seconds 5` 定时退出冒烟 exit 0),按 §8 约定
临时还原预置 pattern_slots.json 后 `package_release.py 0.4.1` 生成
`DLPilot-v0.4.1-win64.zip`(66.8 MB,zip 内为预置 COM15 配置,本机 COM5 运行态
已还原未入库)。**Gitea Release 尚未发布**,上传命令:
`python publish_gitea.py ../release/DLPilot-v0.4.1-win64.zip --replace --insecure`。
- 2026-09-28 第二修:"串口未选/选错点连接后窗口被拉长、刷新按钮够不到"。根因:
pyserial 原始异常串(`could not open port '': FileNotFoundError...`)整段塞进
状态 BodyLabel,QLabel 不换行时最小宽度=整行文本宽,把右列参数行最小宽度撑爆。
三层修复:①`_dlpc_toggle` 空串口直接短提示返回(不再先写 SERIAL_CFG/save_slots
再 open——旧代码这顺序还会把本机串口记忆覆写成空串,本机 COM5 记忆即这样丢失过,
已手工修回);②dlpc_rs485 open 失败消息分类为短提示(被占用/不存在/其它),
完整异常改走 on_event 进收发日志;③`lbl_dlpc_st.setWordWrap(True)` 兜底。
验证:compileall / selftest_rs485 ALL PASS / 离屏断言(空串口提示、长文本下标签
最小宽度<300 可换行)/ GUI 冒烟 / 新 exe `--seconds 5` exit 0;v0.4.1 exe(15:05)
与 zip 均已用该修复重打。
+256
View File
@@ -0,0 +1,256 @@
# DLPilot — HDMI 源端调试 + 光机控制工具
> LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套的 PC 端工具:
> HDMI **源端**(EDID 查看、分辨率切换、纯色/测试图案投屏)+ **光机控制**
> (RS485 直连板上 GD32 网关,开关光机/设光强/查温度电流)。
>
> - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases)
> 下载 `DLPilot-vX.Y.Z-win64.zip`,解压双击 `DLPilot.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
> - **要改代码/重新打包**:见 §8、§9。
>
> 更新:2026-09-24
---
## 1. 功能总览
| 功能 | 说明 |
|---|---|
| EDID 查看 | 枚举各显示输出口,从注册表读取 EDID 原始数据,按 EDID 1.4/CEA-861 解算:厂商/型号/尺寸/Gamma/输入类型/支持模式(DTD/已建立/标准/CEA-VIC)/首选时序完整参数/校验和 |
| EDID 逐字节解析 | "EDID 原始数据"框右上"逐字节解析"按钮:按偏移逐字段展开基础块与 CTA-861 扩展块,每字段附原始字节 hex;命令行等价 `--edid-fields` |
| 分辨率切换 | "分辨率"下拉列出显卡按该屏 EDID 提供的全部模式;**应用**=临时切换(不写注册表,重启/恢复即还原),**恢复**=回到本次会话切改前的模式 |
| 测试图案投屏 | 全屏投到所选显示器,测试卡区域可选 `640x360 / 800x600 / full 铺满`;内置 纯红/纯绿/纯蓝/黑白棋盘格/网格线 + R/G/B 三行 55/AA/FF 纯色值按钮 + 3 个自定义图片栏位 |
| 界面 | PySide6-Fluent-Widgets 双栏仪表盘(左列 HDMI 源端 / 右列 光机控制),顶栏可切换 Dark/Light/System |
| 光机控制 | RS485 → 板载 GD32E230 网关 → DLPC3421:开关光机(可定时)、设光强、查电流/温度/版本、TEC 控制、软件重启;光强/定时提供快捷滑条;收发日志带协议译码(见 §5) |
| 智能显示拓扑 | 仅当检测到转接板(EDID 厂商 "XLS")接入但未进入桌面(Windows 卡在"仅电脑屏幕")时才强制扩展把它拉回;你主动选的"仅第二屏幕"等拓扑不会被改动 |
| 命令行模式 | 全部功能可脚本化调用,供自动化测试(见 §6、§7) |
| 波形分析脚本 | 逻辑分析仪 CSV 波形按占空比签名分段,输出行结构/占空比/消隐明细(见 §7.3) |
## 2. 下载与安装
1. 打开 [Releases 页面](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases),取最新的
`DLPilot-vX.Y.Z-win64.zip`;
2. 解压到**任意可写目录**(自定义图片与栏位配置会写到 exe 旁边);
3. 双击 `DLPilot.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。
包内包含:
```
DLPilot/
├── DLPilot.exe 主程序(单文件自包含, 光机控制的 pyserial 已内置)
├── pattern_slots.json 预置自定义栏位 + 串口记忆
├── bw_*.png ×5 640x360 黑白测试图
├── solid_*.png ×10 800x600 纯色测试图
└── README.md 本说明
```
## 3. GUI 使用
主窗口为双栏仪表盘(布局设计稿:`UI参考布局.svg`),默认跟随系统深/浅色外观
(顶栏可切换 Dark/Light/System),顶栏右侧"关于"按钮内含软件信息:
```
左列(HDMI 源端) 右列(光机控制 RS485)
┌ 显示器选择区域 [刷新] ┐ ┌ 串口选择 + 波特率/8-N-1 参数/连接 ┐
├ 支持的模式: 分辨率[应用][恢复] + 明细 ┐ ├ 电源检查 — PD自检/FPDO挡位/功率读数 ┤
├ EDID 信息 ┘ ├ 串口数据 — 收发日志(译码+hex) ┤
├ 测试图案 / 纯色 / 校准图案 / 测试卡区域 │ ├ 光机命令 — 快捷按钮+TEC │
├ EDID 原始数据 [逐字节解析] ┘ │ 预设下拉 / 温度电流读取 / 自定义 │
└─────────────────────────────┘ └ 快捷滑条 — 光强/定时/TEC温度 ┘
```
1. 左上显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举;
2. **切分辨率**:"支持的模式"卡片里选模式 → "应用";测完点"恢复";
3. **投测试图**:"测试图案"卡片先选**测试卡区域**(640x360 / 800x600 / full)→
点图案按钮全屏投放(新图案自动替换旧图案),按 **ESC** 或点"停止测试图像"退出;
R/G/B 纯色按钮(55/AA/FF,按通道着色)
用于 RGB 位交换类测试;自定义图片点齿轮按钮配置(图片自动复制到 exe 旁),
"校准图案"一排为 3D 光固化测试/校准图预留槽位;
4. **光机控制**:右列选串口与参数(波特率/数据位/校验/停止位,默认 115200 8-N-1)→ 连接 →
快捷按钮/预设下拉/自定义输入(回车即发);"查DMD温度""查LAMP温度"读温度
(应答实时填入按钮旁的显示框);快捷滑条:光强拖动后"下发电流值",
定时毫秒后"开光机(定时)"(0=不限时 M730S0),TEC 凝露点滑条/输入框
(200-350 即 20.0-35.0°C)后"下发温度值";数值框输入后回车自动限幅同步;
5. 测试卡区域、自定义栏位与串口记忆(含参数)自动保存在 exe 旁的 `pattern_slots.json`。
## 4. 典型测试场景
```
1. HDMI 接转接板 → 启动 DLPilot → 刷新 → 选中板卡显示器
2. 板卡 shadow EDID 首选 640x360@60 → 测试卡区域选 640x360(与光机面板原生 1:1)
3. "光机控制"连接串口 → M999 查版本确认链路通 → M730S0 开光机
4. 点 R/G/B 行纯色按钮投图 → 目视/逻辑分析仪核对 RGB888 输出
- 纯红/绿/蓝:验证通道映射(R=D[23:16], G=D[15:8], B=D[7:0])
- 55/AA 对:验证位交换(0x606E)——图像应为 50% 灰阶占空比
5. M731S100 设光强 → M732 查电流读回复核 → M730S1 关光机
```
寄存器语义(0x606E 位交换 / 0x606D 整组顺序)与光机协议的判读基线见上游主仓库
[LT8619C_DLPC3421_HDMI](https://gitea.hulk.wang/hulk/LT8619C_DLPC3421_HDMI) 的 `doc/architecture/`。
## 5. 光机控制(RS485 → GD32 网关 → DLPC3421)
物理链路:PC 经 **USB-RS485 转换器**接板卡 A/B 端子,板上 GD32E230 做 RS485→I2C 网关
控制 DLPC3421(I2C 地址 0x1B)。串口参数 **115200, 8N1**,协议细节以上游固件仓
`protocols.md` 为准:命令帧 `D5 01 LEN <ASCII命令> CRC`、响应帧 `B5 TYPE LEN <payload> CRC`,
CRC 为第 2 字节起累加和取低 8 位。
GUI"光机控制"区:选串口 → 连接 → 点快捷按钮或命令下拉/自定义输入发送;串口下拉显示
"COMx + 设备描述",CH344 等多路适配器每一路描述不同(如 serial-A/B/C/D),据此选口;
多口描述相同时自动附物理位置消歧。日志区实时显示 `→ 命令` / `← 译码结果 (原始 hex)`,
错误自动附释义(如 `err:3(I2C NACK)`)。
"电源检查"卡:**电源自检**依序发 M301(协议)→M302(挡位最大电流)→M303(实测母线电压),
按实测电压就近判定 PD 固定挡(5/9/12/15/20/28V,容差 ±5%):**12V 挡且 ≥3A 绿底
"FPDO: 12V@3A"**;挡位不符(如 9V 挡)**红底**显示实际挡位;不落在任何标准挡显示
**ERR**(CH224Q 电压配置寄存器只写不可读,挡位只能靠实测判定)。**功率检测**依序发
M303+M304+M305,读数框显示 电压(V) / 电流(mA) / 功率(W,mW 换算两位小数)。
| 命令 | 功能 | 响应示例 |
|---|---|---|
| `M730S0` / `M730S1` | 开 / 关光机 | `ok` |
| `M730S0T2000` | 开光机并定时 2000ms 自动关(≤32700) | `ok`(期间 M737 延迟应答) |
| `M731S100` | 设光强 54-800(RGB 同值,写后读回复核) | `ok` / `err:R..G..B..` |
| `M732` | 查三路电流设置值(0-65535) | `okR110G110B110` |
| `M201` / `M202` | 查 DMD / LAMP 温度(0.1°C) | `okTL:246` = 24.6°C |
| `M301` | 查电源协议(CH224Q 握手结果) | ASCII:`PD` / `QC3.0` / `EPR` … |
| `M302` | 查 PD 挡位最大电流(u16 大端 **mA**,50mA/LSB 固件已换算) | 0x0BB8 = 3000 mA = 3 A |
| `M303` | 查母线电压(SQ52205 实测,**2 字节大端二进制载荷**) | 0x2EF4 = 12020 mV = 12.02 V(12V 轨窗口 11400~12600) |
| `M304` | 查负载电流(SQ52205 实测,**2 字节大端二进制载荷**) | s16 补码 mA:0x0034=+52;FF 06=−250(正=正向) |
| `M305` | 查负载功率(SQ52205 实测,**4 字节大端二进制载荷** u32) | mW→显示 W 两位小数:0x00008CA0=36000 mW=36.00 W(量程 0~295000 mW) |
| `M999` | 查固件版本 | `okv20.0.0.1` |
| `M737` | 查投光是否结束(投光中延迟应答) | `ok` |
| `M7S0` / `M7S1` | TEC 自动控温开 / 关(注意 S0=开) | `ok` |
| `M8S330` | TEC 凝露点 33.0°C(200-350,0.1°C) | `ok` / `err:OL` |
| `M1` | 协议握手 | `ok` |
| `M888` | 软件重启 | `ok` 后 100ms 复位 |
注意:**DLPC3421 启动完成前 M730/M731/M732 被就绪门禁拒绝**(回 0xFF),上电后稍等几秒再发。
命令行等价(源码方式):
```
python hdmi_gui.py --port COM9 --dlpc-cmd M730S0 --dlpc-cmd M999
逐条发送并打印译码响应
```
## 6. 命令行模式(hdmi_gui.py)
exe 版无控制台,`--dump/--edid-fields` 的输出不可见(`--dlpc-cmd` 仍可执行,靠退出码判断),
调试请用源码方式:
```
python hdmi_gui.py --dump 控制台输出 EDID 解算
python hdmi_gui.py --edid-fields 控制台输出 EDID 逐字节字段解析表
python hdmi_gui.py --test-pattern Blue --area 800x600 --seconds 5
出图 5 秒自动退出
python hdmi_gui.py --test-pattern Custom1 投自定义栏位 1
python hdmi_gui.py --port COM9 --dlpc-cmd M730S0 RS485 发光机命令(可多次)
python hdmi_gui.py --seconds 5 GUI 冒烟测试, 5 秒自动关
--test-pattern {Red,Green,Blue,Checker,Grid,Custom1..3}
--area {640x360,800x600,full} --monitor N --seconds S
--port COMx --baud 115200 --dlpc-cmd CMD(可重复; 任一命令失败退出码 1)
```
## 7. 辅助脚本(src/ 下)
### 7.1 hdmi_source.bat / .ps1 — 源端命令行工具
与 GUI 互补,适合脚本串联:
```
tools\hdmi_source.bat -List 枚举显示器与支持分辨率(验证 EDID)
tools\hdmi_source.bat -Monitor 1 -Mode 800x600 切模式
tools\hdmi_source.bat -Mode 800x600 -Pattern -Restore
切模式→出图案→退出后自动恢复
```
### 7.2 gen_bw_patterns.py / gen_solid_colors.py — 测试图生成
需 Pillow(`py -m pip install pillow`)。重新生成 `assets/` 下全部图案:
- `gen_bw_patterns.py` → 640x360 黑白图 ×5:
`bw_star.png` 西门子星(极限分辨率)、`bw_sweep.png` 频率扫描条纹(24px→3px)、
`bw_checker_sweep.png` 渐变棋盘(2→32px)、`bw_zoneplate.png` 波带片(各向混叠)、
`bw_testcard.png` 综合测试卡(灰阶/刻度/细棋盘/线宽组);
- `gen_solid_colors.py` → 800x600 纯色图 ×10(R/G/B 各 55/AA/FF + 三原色),
与 GUI 内置纯色按钮同源,供自定义栏位或其他工具加载。
### 7.3 la_wave_analyzer.py / la_wave_analyzer3.py — 逻辑分析仪波形判读
分析 Kingst VIS 导出的边沿流 CSV(列:`Time[s], 通道A, 通道B[, 通道C]`,
10ns 分辨率),按 50ms 窗口占空比签名自动分段,输出每段的签名(FF/55/AA/00)、
时长、行周期/有效区宽度/消隐(按 40MHz 像素时钟折算):
```
python la_wave_analyzer.py <capture.csv> # 2 通道(默认判读 B 口 D0/D1)
python la_wave_analyzer3.py <capture.csv> # 3 通道(RGB 三组同拍, ch8/9/10)
```
### 7.4 selftest_rs485.py — 光机协议自检(无硬件)
对照上游 `protocols.md` 实测帧向量回归帧构造/解析/译码:`python selftest_rs485.py`,
全部通过退出码 0。改 `dlpc_rs485.py` 后必须跑一遍。
## 8. 目录结构与从源码运行
```
DLPilot/
├── src/ # 源代码(人工维护区)
│ ├── hdmi_gui.py # 入口与命令行模式(argparse 分发)
│ ├── app_fluent.py # PySide6-Fluent-Widgets 主界面(双栏仪表盘)
│ ├── app_gui.py # 历史 CustomTkinter 界面(保留,不由入口加载)
│ ├── display_win.py # Win32 显示器枚举/分辨率/智能拓扑(纯标准库)
│ ├── edid_parse.py # EDID 1.4/CEA-861 解析(纯标准库)
│ ├── patterns.py # 测试图案全屏投屏窗口
│ ├── config.py # 运行目录与 pattern_slots.json 配置
│ ├── dlpc_rs485.py # 光机 RS485 协议与串口链路
│ ├── selftest_rs485.py # 光机协议自检(无硬件回归)
│ ├── build_exe.py # PyInstaller 打包 → release/
│ ├── package_release.py # 组装发布 zip(exe+图案+说明)
│ ├── publish_gitea.py # 上传到 Gitea Release
│ ├── hdmi_source.ps1/.bat # 源端分辨率/图案命令行工具
│ ├── gen_bw_patterns.py # 生成 assets/bw_*.png
│ ├── gen_solid_colors.py # 生成 assets/solid_*.png
│ ├── la_wave_analyzer*.py # 逻辑分析仪 CSV 波形判读
│ └── logos/ # 图标源(svg/png/ico + make_ico.py)
├── assets/ # 生成的测试图案(gen_*.py 产物, 随发布包分发)
│ ├── bw_*.png ×5 # 640x360 黑白系列
│ └── solid_*.png ×10 # 800x600 纯色系列
├── release/ # 构建与发布产物(机器生成区, 不手改)
│ ├── DLPilot.exe # 打包单文件版(改动跟随源码提交入库)
│ ├── pattern_slots.json # exe 运行配置(栏位预置 + 串口记忆)
│ ├── *.png / README.md # 附属文件平铺副本(由 package_release.py 从 assets/ 同步)
│ └── *.zip # 发布包(不入库, 上传 Gitea Release)
├── README.md # 本说明
├── AGENTS.md # 面向 AI/Agent 的工程档案(结构/约定/构建规程)
└── UI参考布局.svg/.png # 界面布局设计稿(app_fluent.py 双栏布局参考)
```
从源码运行:`python src/hdmi_gui.py`,需 Python ≥3.10;EDID/命令行纯标准库;
GUI 需 PySide6-Fluent-Widgets(`py -m pip install PySide6-Fluent-Widgets`);光机控制需 pyserial;
自定义图片要 JPG/BMP/WebP 需可选 Pillow。
## 9. 构建与发布
```powershell
cd src
python selftest_rs485.py # 光机协议回归(改 dlpc_rs485.py 后必跑)
python build_exe.py # 打包 → ../release/DLPilot.exe(自动装 PyInstaller;
# 内置 pyserial/PySide6-Fluent-Widgets, Fluent 主题资源自动收集)
python build_exe.py --debug # 额外出一个带控制台的调试变体(--dump 可见输出)
python package_release.py 0.2.0 # 组装 release/DLPilot-v0.2.0-win64.zip
python publish_gitea.py DLPilot-v0.2.0-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN)
```
## 10. 注意事项 / FAQ
- **光机没反应排查**:① 串口/波特率(115200 8N1)与 A/B 接线;② 先发 `M999` 验证链路;
③ 开光机回 0xFF 是 DLPC 就绪门禁——上电后等几秒;④ USB-RS485 转换器半双工,
发送时板上回包不会回环(正常);
- **exe 放只读目录会怎样**:自定义图片与 `pattern_slots.json` 写在 exe 旁边,只读目录会导致配置存不下;
- **重新打包后图标没变**:Windows 图标缓存,`ie4uinit -show` 或改个名即可;
- **多屏序号**:切分辨率前先确认显示器序号(GUI 下拉框与 `hdmi_source.bat -List` 输出一致);
- **残留进程**:修改源码重新打包前先结束残留的 DLPilot 进程(`build_exe.py` 会自动 taskkill);
- **改 logo**:改 `src/logos/logo_ph_orange.svg` → `python src/logos/make_ico.py` → 重新打包;
- **配置互不影响**:exe 与源码脚本各自按所在目录保存 `pattern_slots.json`,两份安装互不干扰。
+168
View File
@@ -0,0 +1,168 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="343.44351mm"
height="287.24921mm"
viewBox="0 0 343.44351 287.24921"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="layer1"
transform="translate(-276.89459,-7.1259639)">
<rect
style="fill:none;stroke:#000000;stroke-width:0.264583"
id="rect2"
width="107.61474"
height="17.995777"
x="310.24719"
y="19.795353" />
<text
xml:space="preserve"
style="font-size:7.05556px;text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
x="329.95258"
y="30.952734"
id="text2"><tspan
id="tspan2"
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';fill:#000000;stroke:none;stroke-width:0.264583"
x="329.95258"
y="30.952734">显示器选择区域</tspan></text>
<rect
style="fill:none;stroke:#000000;stroke-width:0.264583"
id="rect2-8"
width="107.61474"
height="17.995777"
x="476.34061"
y="19.478064" />
<text
xml:space="preserve"
style="font-size:7.05556px;text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
x="496.04599"
y="30.635443"
id="text2-8"><tspan
id="tspan2-2"
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';fill:#000000;stroke:none;stroke-width:0.264583"
x="496.04599"
y="30.635443">串口选择区域</tspan></text>
<text
xml:space="preserve"
style="font-size:7.05556px;text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
x="498.63492"
y="52.226608"
id="text2-8-7"><tspan
id="tspan2-2-6"
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';fill:#000000;stroke:none;stroke-width:0.264583"
x="498.63492"
y="52.226608">串口参数选择显示</tspan></text>
<text
xml:space="preserve"
style="font-size:7.05556px;text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
x="341.34952"
y="75.572121"
id="text2-8-1"><tspan
id="tspan2-2-7"
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';fill:#000000;stroke:none;stroke-width:0.264583"
x="341.34952"
y="75.572121">支持的模式</tspan></text>
<rect
style="fill:none;stroke:#000000;stroke-width:0.264583"
id="rect3"
width="150.08478"
height="73.782684"
x="296.5704"
y="45.709274" />
<rect
style="fill:none;stroke:#000000;stroke-width:0.219727"
id="rect4"
width="149.76971"
height="63.030079"
x="297.26779"
y="124.14843" />
<rect
style="fill:none;stroke:#000000;stroke-width:0.324039"
id="rect4-21"
width="148.22574"
height="138.50803"
x="462.45538"
y="65.445747" />
<rect
style="fill:none;stroke:#000000;stroke-width:0.178131"
id="rect4-2"
width="150.89105"
height="41.116825"
x="297.18097"
y="197.28166" />
<rect
style="fill:none;stroke:#000000;stroke-width:0.183274"
id="rect4-2-4"
width="150.52599"
height="43.631092"
x="297.79538"
y="244.01398" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
x="344.43915"
y="162.68181"
id="text4"><tspan
id="tspan4"
style="fill:#000000;stroke:none;stroke-width:0.264583"
x="344.43915"
y="162.68181">EDID信息</tspan></text>
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
x="309.88724"
y="220.62822"
id="text6"><tspan
id="tspan6"
style="stroke-width:0.264583"
x="309.88724"
y="220.62822">测试的图案、纯色、自定义图案等区域</tspan></text>
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
x="342.27966"
y="259.85901"
id="text7"><tspan
id="tspan7"
style="stroke-width:0.264583"
x="342.27966"
y="259.85901">EDID原始数据</tspan></text>
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
x="511.7999"
y="118.41221"
id="text8"><tspan
id="tspan8"
style="stroke-width:0.264583"
x="511.7999"
y="118.41221">串口数据区域</tspan></text>
<rect
style="fill:none;stroke:#000000;stroke-width:0.264583"
id="rect8"
width="145.40587"
height="70.903358"
x="464.29105"
y="213.78983" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
x="498.483"
y="240.06366"
id="text9"><tspan
id="tspan9"
style="fill:#000000;stroke:none;stroke-width:0.264583"
x="498.483"
y="240.06366">预设按钮、自定义命令</tspan><tspan
style="fill:#000000;stroke:none;stroke-width:0.264583"
x="498.483"
y="248.96394"
id="tspan10">亮度、时间等快捷滑条区域</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+256
View File
@@ -0,0 +1,256 @@
# DLPilot — HDMI 源端调试 + 光机控制工具
> LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套的 PC 端工具:
> HDMI **源端**(EDID 查看、分辨率切换、纯色/测试图案投屏)+ **光机控制**
> (RS485 直连板上 GD32 网关,开关光机/设光强/查温度电流)。
>
> - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases)
> 下载 `DLPilot-vX.Y.Z-win64.zip`,解压双击 `DLPilot.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
> - **要改代码/重新打包**:见 §8、§9。
>
> 更新:2026-09-24
---
## 1. 功能总览
| 功能 | 说明 |
|---|---|
| EDID 查看 | 枚举各显示输出口,从注册表读取 EDID 原始数据,按 EDID 1.4/CEA-861 解算:厂商/型号/尺寸/Gamma/输入类型/支持模式(DTD/已建立/标准/CEA-VIC)/首选时序完整参数/校验和 |
| EDID 逐字节解析 | "EDID 原始数据"框右上"逐字节解析"按钮:按偏移逐字段展开基础块与 CTA-861 扩展块,每字段附原始字节 hex;命令行等价 `--edid-fields` |
| 分辨率切换 | "分辨率"下拉列出显卡按该屏 EDID 提供的全部模式;**应用**=临时切换(不写注册表,重启/恢复即还原),**恢复**=回到本次会话切改前的模式 |
| 测试图案投屏 | 全屏投到所选显示器,测试卡区域可选 `640x360 / 800x600 / full 铺满`;内置 纯红/纯绿/纯蓝/黑白棋盘格/网格线 + R/G/B 三行 55/AA/FF 纯色值按钮 + 3 个自定义图片栏位 |
| 界面 | PySide6-Fluent-Widgets 双栏仪表盘(左列 HDMI 源端 / 右列 光机控制),顶栏可切换 Dark/Light/System |
| 光机控制 | RS485 → 板载 GD32E230 网关 → DLPC3421:开关光机(可定时)、设光强、查电流/温度/版本、TEC 控制、软件重启;光强/定时提供快捷滑条;收发日志带协议译码(见 §5) |
| 智能显示拓扑 | 仅当检测到转接板(EDID 厂商 "XLS")接入但未进入桌面(Windows 卡在"仅电脑屏幕")时才强制扩展把它拉回;你主动选的"仅第二屏幕"等拓扑不会被改动 |
| 命令行模式 | 全部功能可脚本化调用,供自动化测试(见 §6、§7) |
| 波形分析脚本 | 逻辑分析仪 CSV 波形按占空比签名分段,输出行结构/占空比/消隐明细(见 §7.3) |
## 2. 下载与安装
1. 打开 [Releases 页面](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases),取最新的
`DLPilot-vX.Y.Z-win64.zip`;
2. 解压到**任意可写目录**(自定义图片与栏位配置会写到 exe 旁边);
3. 双击 `DLPilot.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。
包内包含:
```
DLPilot/
├── DLPilot.exe 主程序(单文件自包含, 光机控制的 pyserial 已内置)
├── pattern_slots.json 预置自定义栏位 + 串口记忆
├── bw_*.png ×5 640x360 黑白测试图
├── solid_*.png ×10 800x600 纯色测试图
└── README.md 本说明
```
## 3. GUI 使用
主窗口为双栏仪表盘(布局设计稿:`UI参考布局.svg`),默认跟随系统深/浅色外观
(顶栏可切换 Dark/Light/System),顶栏右侧"关于"按钮内含软件信息:
```
左列(HDMI 源端) 右列(光机控制 RS485)
┌ 显示器选择区域 [刷新] ┐ ┌ 串口选择 + 波特率/8-N-1 参数/连接 ┐
├ 支持的模式: 分辨率[应用][恢复] + 明细 ┐ ├ 电源检查 — PD自检/FPDO挡位/功率读数 ┤
├ EDID 信息 ┘ ├ 串口数据 — 收发日志(译码+hex) ┤
├ 测试图案 / 纯色 / 校准图案 / 测试卡区域 │ ├ 光机命令 — 快捷按钮+TEC │
├ EDID 原始数据 [逐字节解析] ┘ │ 预设下拉 / 温度电流读取 / 自定义 │
└─────────────────────────────┘ └ 快捷滑条 — 光强/定时/TEC温度 ┘
```
1. 左上显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举;
2. **切分辨率**:"支持的模式"卡片里选模式 → "应用";测完点"恢复";
3. **投测试图**:"测试图案"卡片先选**测试卡区域**(640x360 / 800x600 / full)→
点图案按钮全屏投放(新图案自动替换旧图案),按 **ESC** 或点"停止测试图像"退出;
R/G/B 纯色按钮(55/AA/FF,按通道着色)
用于 RGB 位交换类测试;自定义图片点齿轮按钮配置(图片自动复制到 exe 旁),
"校准图案"一排为 3D 光固化测试/校准图预留槽位;
4. **光机控制**:右列选串口与参数(波特率/数据位/校验/停止位,默认 115200 8-N-1)→ 连接 →
快捷按钮/预设下拉/自定义输入(回车即发);"查DMD温度""查LAMP温度"读温度
(应答实时填入按钮旁的显示框);快捷滑条:光强拖动后"下发电流值",
定时毫秒后"开光机(定时)"(0=不限时 M730S0),TEC 凝露点滑条/输入框
(200-350 即 20.0-35.0°C)后"下发温度值";数值框输入后回车自动限幅同步;
5. 测试卡区域、自定义栏位与串口记忆(含参数)自动保存在 exe 旁的 `pattern_slots.json`。
## 4. 典型测试场景
```
1. HDMI 接转接板 → 启动 DLPilot → 刷新 → 选中板卡显示器
2. 板卡 shadow EDID 首选 640x360@60 → 测试卡区域选 640x360(与光机面板原生 1:1)
3. "光机控制"连接串口 → M999 查版本确认链路通 → M730S0 开光机
4. 点 R/G/B 行纯色按钮投图 → 目视/逻辑分析仪核对 RGB888 输出
- 纯红/绿/蓝:验证通道映射(R=D[23:16], G=D[15:8], B=D[7:0])
- 55/AA 对:验证位交换(0x606E)——图像应为 50% 灰阶占空比
5. M731S100 设光强 → M732 查电流读回复核 → M730S1 关光机
```
寄存器语义(0x606E 位交换 / 0x606D 整组顺序)与光机协议的判读基线见上游主仓库
[LT8619C_DLPC3421_HDMI](https://gitea.hulk.wang/hulk/LT8619C_DLPC3421_HDMI) 的 `doc/architecture/`。
## 5. 光机控制(RS485 → GD32 网关 → DLPC3421)
物理链路:PC 经 **USB-RS485 转换器**接板卡 A/B 端子,板上 GD32E230 做 RS485→I2C 网关
控制 DLPC3421(I2C 地址 0x1B)。串口参数 **115200, 8N1**,协议细节以上游固件仓
`protocols.md` 为准:命令帧 `D5 01 LEN <ASCII命令> CRC`、响应帧 `B5 TYPE LEN <payload> CRC`,
CRC 为第 2 字节起累加和取低 8 位。
GUI"光机控制"区:选串口 → 连接 → 点快捷按钮或命令下拉/自定义输入发送;串口下拉显示
"COMx + 设备描述",CH344 等多路适配器每一路描述不同(如 serial-A/B/C/D),据此选口;
多口描述相同时自动附物理位置消歧。日志区实时显示 `→ 命令` / `← 译码结果 (原始 hex)`,
错误自动附释义(如 `err:3(I2C NACK)`)。
"电源检查"卡:**电源自检**依序发 M301(协议)→M302(挡位最大电流)→M303(实测母线电压),
按实测电压就近判定 PD 固定挡(5/9/12/15/20/28V,容差 ±5%):**12V 挡且 ≥3A 绿底
"FPDO: 12V@3A"**;挡位不符(如 9V 挡)**红底**显示实际挡位;不落在任何标准挡显示
**ERR**(CH224Q 电压配置寄存器只写不可读,挡位只能靠实测判定)。**功率检测**依序发
M303+M304+M305,读数框显示 电压(V) / 电流(mA) / 功率(W,mW 换算两位小数)。
| 命令 | 功能 | 响应示例 |
|---|---|---|
| `M730S0` / `M730S1` | 开 / 关光机 | `ok` |
| `M730S0T2000` | 开光机并定时 2000ms 自动关(≤32700) | `ok`(期间 M737 延迟应答) |
| `M731S100` | 设光强 54-800(RGB 同值,写后读回复核) | `ok` / `err:R..G..B..` |
| `M732` | 查三路电流设置值(0-65535) | `okR110G110B110` |
| `M201` / `M202` | 查 DMD / LAMP 温度(0.1°C) | `okTL:246` = 24.6°C |
| `M301` | 查电源协议(CH224Q 握手结果) | ASCII:`PD` / `QC3.0` / `EPR` … |
| `M302` | 查 PD 挡位最大电流(u16 大端 **mA**,50mA/LSB 固件已换算) | 0x0BB8 = 3000 mA = 3 A |
| `M303` | 查母线电压(SQ52205 实测,**2 字节大端二进制载荷**) | 0x2EF4 = 12020 mV = 12.02 V(12V 轨窗口 11400~12600) |
| `M304` | 查负载电流(SQ52205 实测,**2 字节大端二进制载荷**) | s16 补码 mA:0x0034=+52;FF 06=−250(正=正向) |
| `M305` | 查负载功率(SQ52205 实测,**4 字节大端二进制载荷** u32) | mW→显示 W 两位小数:0x00008CA0=36000 mW=36.00 W(量程 0~295000 mW) |
| `M999` | 查固件版本 | `okv20.0.0.1` |
| `M737` | 查投光是否结束(投光中延迟应答) | `ok` |
| `M7S0` / `M7S1` | TEC 自动控温开 / 关(注意 S0=开) | `ok` |
| `M8S330` | TEC 凝露点 33.0°C(200-350,0.1°C) | `ok` / `err:OL` |
| `M1` | 协议握手 | `ok` |
| `M888` | 软件重启 | `ok` 后 100ms 复位 |
注意:**DLPC3421 启动完成前 M730/M731/M732 被就绪门禁拒绝**(回 0xFF),上电后稍等几秒再发。
命令行等价(源码方式):
```
python hdmi_gui.py --port COM9 --dlpc-cmd M730S0 --dlpc-cmd M999
逐条发送并打印译码响应
```
## 6. 命令行模式(hdmi_gui.py)
exe 版无控制台,`--dump/--edid-fields` 的输出不可见(`--dlpc-cmd` 仍可执行,靠退出码判断),
调试请用源码方式:
```
python hdmi_gui.py --dump 控制台输出 EDID 解算
python hdmi_gui.py --edid-fields 控制台输出 EDID 逐字节字段解析表
python hdmi_gui.py --test-pattern Blue --area 800x600 --seconds 5
出图 5 秒自动退出
python hdmi_gui.py --test-pattern Custom1 投自定义栏位 1
python hdmi_gui.py --port COM9 --dlpc-cmd M730S0 RS485 发光机命令(可多次)
python hdmi_gui.py --seconds 5 GUI 冒烟测试, 5 秒自动关
--test-pattern {Red,Green,Blue,Checker,Grid,Custom1..3}
--area {640x360,800x600,full} --monitor N --seconds S
--port COMx --baud 115200 --dlpc-cmd CMD(可重复; 任一命令失败退出码 1)
```
## 7. 辅助脚本(src/ 下)
### 7.1 hdmi_source.bat / .ps1 — 源端命令行工具
与 GUI 互补,适合脚本串联:
```
tools\hdmi_source.bat -List 枚举显示器与支持分辨率(验证 EDID)
tools\hdmi_source.bat -Monitor 1 -Mode 800x600 切模式
tools\hdmi_source.bat -Mode 800x600 -Pattern -Restore
切模式→出图案→退出后自动恢复
```
### 7.2 gen_bw_patterns.py / gen_solid_colors.py — 测试图生成
需 Pillow(`py -m pip install pillow`)。重新生成 `assets/` 下全部图案:
- `gen_bw_patterns.py` → 640x360 黑白图 ×5:
`bw_star.png` 西门子星(极限分辨率)、`bw_sweep.png` 频率扫描条纹(24px→3px)、
`bw_checker_sweep.png` 渐变棋盘(2→32px)、`bw_zoneplate.png` 波带片(各向混叠)、
`bw_testcard.png` 综合测试卡(灰阶/刻度/细棋盘/线宽组);
- `gen_solid_colors.py` → 800x600 纯色图 ×10(R/G/B 各 55/AA/FF + 三原色),
与 GUI 内置纯色按钮同源,供自定义栏位或其他工具加载。
### 7.3 la_wave_analyzer.py / la_wave_analyzer3.py — 逻辑分析仪波形判读
分析 Kingst VIS 导出的边沿流 CSV(列:`Time[s], 通道A, 通道B[, 通道C]`,
10ns 分辨率),按 50ms 窗口占空比签名自动分段,输出每段的签名(FF/55/AA/00)、
时长、行周期/有效区宽度/消隐(按 40MHz 像素时钟折算):
```
python la_wave_analyzer.py <capture.csv> # 2 通道(默认判读 B 口 D0/D1)
python la_wave_analyzer3.py <capture.csv> # 3 通道(RGB 三组同拍, ch8/9/10)
```
### 7.4 selftest_rs485.py — 光机协议自检(无硬件)
对照上游 `protocols.md` 实测帧向量回归帧构造/解析/译码:`python selftest_rs485.py`,
全部通过退出码 0。改 `dlpc_rs485.py` 后必须跑一遍。
## 8. 目录结构与从源码运行
```
DLPilot/
├── src/ # 源代码(人工维护区)
│ ├── hdmi_gui.py # 入口与命令行模式(argparse 分发)
│ ├── app_fluent.py # PySide6-Fluent-Widgets 主界面(双栏仪表盘)
│ ├── app_gui.py # 历史 CustomTkinter 界面(保留,不由入口加载)
│ ├── display_win.py # Win32 显示器枚举/分辨率/智能拓扑(纯标准库)
│ ├── edid_parse.py # EDID 1.4/CEA-861 解析(纯标准库)
│ ├── patterns.py # 测试图案全屏投屏窗口
│ ├── config.py # 运行目录与 pattern_slots.json 配置
│ ├── dlpc_rs485.py # 光机 RS485 协议与串口链路
│ ├── selftest_rs485.py # 光机协议自检(无硬件回归)
│ ├── build_exe.py # PyInstaller 打包 → release/
│ ├── package_release.py # 组装发布 zip(exe+图案+说明)
│ ├── publish_gitea.py # 上传到 Gitea Release
│ ├── hdmi_source.ps1/.bat # 源端分辨率/图案命令行工具
│ ├── gen_bw_patterns.py # 生成 assets/bw_*.png
│ ├── gen_solid_colors.py # 生成 assets/solid_*.png
│ ├── la_wave_analyzer*.py # 逻辑分析仪 CSV 波形判读
│ └── logos/ # 图标源(svg/png/ico + make_ico.py)
├── assets/ # 生成的测试图案(gen_*.py 产物, 随发布包分发)
│ ├── bw_*.png ×5 # 640x360 黑白系列
│ └── solid_*.png ×10 # 800x600 纯色系列
├── release/ # 构建与发布产物(机器生成区, 不手改)
│ ├── DLPilot.exe # 打包单文件版(改动跟随源码提交入库)
│ ├── pattern_slots.json # exe 运行配置(栏位预置 + 串口记忆)
│ ├── *.png / README.md # 附属文件平铺副本(由 package_release.py 从 assets/ 同步)
│ └── *.zip # 发布包(不入库, 上传 Gitea Release)
├── README.md # 本说明
├── AGENTS.md # 面向 AI/Agent 的工程档案(结构/约定/构建规程)
└── UI参考布局.svg/.png # 界面布局设计稿(app_fluent.py 双栏布局参考)
```
从源码运行:`python src/hdmi_gui.py`,需 Python ≥3.10;EDID/命令行纯标准库;
GUI 需 PySide6-Fluent-Widgets(`py -m pip install PySide6-Fluent-Widgets`);光机控制需 pyserial;
自定义图片要 JPG/BMP/WebP 需可选 Pillow。
## 9. 构建与发布
```powershell
cd src
python selftest_rs485.py # 光机协议回归(改 dlpc_rs485.py 后必跑)
python build_exe.py # 打包 → ../release/DLPilot.exe(自动装 PyInstaller;
# 内置 pyserial/PySide6-Fluent-Widgets, Fluent 主题资源自动收集)
python build_exe.py --debug # 额外出一个带控制台的调试变体(--dump 可见输出)
python package_release.py 0.2.0 # 组装 release/DLPilot-v0.2.0-win64.zip
python publish_gitea.py DLPilot-v0.2.0-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN)
```
## 10. 注意事项 / FAQ
- **光机没反应排查**:① 串口/波特率(115200 8N1)与 A/B 接线;② 先发 `M999` 验证链路;
③ 开光机回 0xFF 是 DLPC 就绪门禁——上电后等几秒;④ USB-RS485 转换器半双工,
发送时板上回包不会回环(正常);
- **exe 放只读目录会怎样**:自定义图片与 `pattern_slots.json` 写在 exe 旁边,只读目录会导致配置存不下;
- **重新打包后图标没变**:Windows 图标缓存,`ie4uinit -show` 或改个名即可;
- **多屏序号**:切分辨率前先确认显示器序号(GUI 下拉框与 `hdmi_source.bat -List` 输出一致);
- **残留进程**:修改源码重新打包前先结束残留的 DLPilot 进程(`build_exe.py` 会自动 taskkill);
- **改 logo**:改 `src/logos/logo_ph_orange.svg` → `python src/logos/make_ico.py` → 重新打包;
- **配置互不影响**:exe 与源码脚本各自按所在目录保存 `pattern_slots.json`,两份安装互不干扰。
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+52
View File
@@ -0,0 +1,52 @@
{
"area": "640x360",
"slots": [
{
"name": "西门子星",
"file": "bw_star.png"
},
{
"name": "BW_ZONE",
"file": "bw_zoneplate.png"
},
{
"name": "BW_TEST",
"file": "bw_testcard.png"
},
{
"name": "自定义4",
"file": null
},
{
"name": "自定义5",
"file": null
},
{
"name": "自定义6",
"file": null
},
{
"name": "自定义7",
"file": null
},
{
"name": "自定义8",
"file": null
},
{
"name": "自定义9",
"file": null
},
{
"name": "自定义10",
"file": null
}
],
"serial": {
"port": "COM15",
"baud": 115200,
"bytesize": 8,
"parity": "N",
"stopbits": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+885
View File
@@ -0,0 +1,885 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""DLPilot Fluent UI.
The device, EDID and RS485 modules deliberately remain unchanged. This file
is the presentation layer for the current desktop application and uses
PySide6-Fluent-Widgets for the cards, controls and dialogs.
"""
import ctypes
import os
import queue
import re
import sys
import time
# PyInstaller keeps Shiboken's ABI DLLs in a sibling directory. Register both
# Qt and Shiboken before importing QtCore; this is required by one-file builds
# on Windows/Python 3.14 where the normal package import hook is unavailable.
if getattr(sys, "frozen", False) and os.name == "nt":
_frozen_dll_dirs = []
for _dll_dir in (os.path.join(getattr(sys, "_MEIPASS", ""), "PySide6"),
os.path.join(getattr(sys, "_MEIPASS", ""), "shiboken6")):
if os.path.isdir(_dll_dir):
_frozen_dll_dirs.append(_dll_dir)
try:
os.add_dll_directory(_dll_dir)
except (AttributeError, OSError):
pass
# Python 3.14 extension loading may ignore AddDllDirectory for a pyd's
# transitive dependencies; PATH keeps QtCore.pyd compatible with both
# Python 3.11-3.14 and PyInstaller one-file extraction.
if _frozen_dll_dirs:
os.environ["PATH"] = os.pathsep.join(_frozen_dll_dirs + [os.environ.get("PATH", "")])
# Preload the two libraries used by QtCore.pyd. This also covers Windows
# loader configurations that do not apply PATH/AddDllDirectory to a pyd's
# transitive dependencies after PyInstaller extraction.
try:
ctypes.WinDLL(os.path.join(getattr(sys, "_MEIPASS", ""), "shiboken6",
"shiboken6.abi3.dll"))
ctypes.WinDLL(os.path.join(getattr(sys, "_MEIPASS", ""), "PySide6",
"Qt6Core.dll"))
except (AttributeError, OSError):
pass
from PySide6.QtCore import QPoint, QRect, Qt, QTimer
from PySide6.QtGui import QColor, QFont, QIcon, QImage, QPainter, QPen, QPalette
from PySide6.QtWidgets import (
QApplication, QDialog, QFileDialog, QFormLayout, QGridLayout, QHBoxLayout,
QLabel, QMainWindow, QMessageBox, QPlainTextEdit, QScrollArea, QSplitter,
QToolButton, QVBoxLayout, QWidget,
)
from qfluentwidgets import (
BodyLabel, CardWidget, ComboBox, LineEdit, PlainTextEdit,
PrimaryPushButton, PushButton, Slider, StrongBodyLabel, Theme,
setCustomStyleSheet, setTheme,
)
import config
import display_win
import dlpc_rs485
import edid_parse
import patterns
APP_NAME = "DLPilot"
APP_VERSION = "0.4.1"
REPO_URL = "https://gitea.hulk.wang/hulk/HW-Debug-Tools"
MONO = QFont("Consolas", 10)
CHIP_IDLE = "#666666"
CHIP_OK = "#2FA574"
CHIP_ERR = "#D9534F"
_QT_PATTERNS = set()
def close_qt_patterns():
for window in list(_QT_PATTERNS):
window.close()
_QT_PATTERNS.clear()
patterns.close_all()
def _area_box(spec, width, height):
if spec == "full":
return 0, 0, width, height
try:
aw, ah = (int(v) for v in spec.lower().split("x", 1))
except Exception:
aw, ah = config.PAT_W, config.PAT_H
return (width - aw) // 2, (height - ah) // 2, aw, ah
class PatternWindow(QWidget):
"""Borderless Qt equivalent of the old tkinter test-pattern window."""
def __init__(self, rect, kind, area, image_path=None, parent=None):
super().__init__(parent)
self.kind = kind
self.area = area
self.image = QImage(image_path) if image_path else None
x, y, width, height = rect
self.setGeometry(x, y, width, height)
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint |
Qt.Tool)
self.setAttribute(Qt.WA_DeleteOnClose)
_QT_PATTERNS.add(self)
self.show()
self.activateWindow()
self.setFocus()
def keyPressEvent(self, event):
if event.key() == Qt.Key_Escape:
self.close()
else:
super().keyPressEvent(event)
def closeEvent(self, event):
_QT_PATTERNS.discard(self)
super().closeEvent(event)
def paintEvent(self, _event):
painter = QPainter(self)
painter.fillRect(self.rect(), Qt.black)
x0, y0, aw, ah = _area_box(self.area, self.width(), self.height())
if self.image and not self.image.isNull():
painter.drawImage(QPoint(x0 + (aw - self.image.width()) // 2,
y0 + (ah - self.image.height()) // 2),
self.image)
return
if self.kind.startswith("#"):
painter.fillRect(x0, y0, aw, ah, QColor(self.kind))
elif self.kind in {"Red", "Green", "Blue"}:
painter.fillRect(x0, y0, aw, ah,
QColor({"Red": "#ff0000", "Green": "#00ff00",
"Blue": "#0000ff"}[self.kind]))
elif self.kind == "Checker":
for gy in range((ah + 39) // 40):
for gx in range((aw + 39) // 40):
if (gx + gy) % 2 == 0:
painter.fillRect(x0 + gx * 40, y0 + gy * 40, 40, 40,
Qt.white)
elif self.kind == "Grid":
painter.setPen(QPen(Qt.white, 1))
for x in range(0, aw + 1, 20):
painter.drawLine(x0 + x, y0, x0 + x, y0 + ah)
for y in range(0, ah + 1, 20):
painter.drawLine(x0, y0 + y, x0 + aw, y0 + y)
def _single_instance():
if os.name != "nt":
return True
try:
ctypes.windll.kernel32.CreateMutexW(None, False, "hulk.DLPilot.single")
return ctypes.windll.kernel32.GetLastError() != 183
except Exception:
return True
class App(QMainWindow):
def __init__(self, auto_close=0):
super().__init__()
app = QApplication.instance()
self._system_palette = app.palette() if app else QPalette()
self._system_style = app.styleSheet() if app else ""
self.setWindowTitle(f"{APP_NAME} v{APP_VERSION} — 3D 光固化光机调试台")
icon_path = self._icon_path()
if icon_path:
self.setWindowIcon(QIcon(icon_path))
self.resize(1440, 960)
self.setMinimumSize(1180, 760)
self.displays = []
self._disp_labels = []
self._modes = {}
self._orig_modes = {}
self._cur_label = ""
self.slots, self.area = config.load_slots()
self._dlpc_log_q = queue.Queue()
self.dlpc = dlpc_rs485.DLPC485(self._dlpc_on_event, self._dlpc_on_status,
on_frame=self._dlpc_on_frame)
self._dlpc_connected = False
self._tl_order = []
self._pd_seq = []
self._pd_values = {}
self._pd_timer = None
self._pwr_seq = []
self._pwr_values = {}
self._pwr_timer = None
self._build()
self._load_displays()
self._timer = QTimer(self)
self._timer.timeout.connect(self._poll_dlpc)
if dlpc_rs485.HAS_SERIAL:
self._timer.start(80)
if auto_close > 0:
QTimer.singleShot(auto_close * 1000, self.close)
@staticmethod
def _icon_path():
base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
path = os.path.join(base, "logo_dlpilot.ico")
if os.path.exists(path):
return path
path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"logos", "logo_dlpilot.ico")
return path if os.path.exists(path) else None
def _button(self, text, callback, primary=False):
button = PrimaryPushButton(text) if primary else PushButton(text)
button.clicked.connect(callback)
return button
def _card(self, title, parent):
card = CardWidget(parent)
layout = QVBoxLayout(card)
layout.setContentsMargins(14, 12, 14, 14)
layout.setSpacing(8)
row = QHBoxLayout()
row.addWidget(StrongBodyLabel(title, card))
row.addStretch(1)
layout.addLayout(row)
return card, layout
def _collapsible_card(self, title, parent, expanded=False):
"""Card whose body starts collapsed to keep the dashboard compact."""
card = CardWidget(parent)
layout = QVBoxLayout(card)
layout.setContentsMargins(8, 6, 8, 8)
layout.setSpacing(4)
header = QToolButton(card)
header.setText(title)
header.setCheckable(True)
header.setChecked(expanded)
header.setAutoRaise(True)
header.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
header.setArrowType(Qt.DownArrow if expanded else Qt.RightArrow)
header.setStyleSheet("QToolButton { text-align: left; font-weight: 700; padding: 6px; }")
layout.addWidget(header)
body = QWidget(card)
body_layout = QVBoxLayout(body)
body_layout.setContentsMargins(6, 0, 6, 4)
body_layout.setSpacing(8)
body.setVisible(expanded)
layout.addWidget(body)
def toggle(checked):
body.setVisible(checked)
header.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
card.adjustSize()
header.clicked.connect(toggle)
return card, body_layout
def _text(self, read_only=True, mono=True):
text = PlainTextEdit(self)
text.setReadOnly(read_only)
if mono:
text.setFont(MONO)
text.setLineWrapMode(QPlainTextEdit.NoWrap)
return text
def _build(self):
root = QWidget(self)
root.setObjectName("root")
outer = QVBoxLayout(root)
outer.setContentsMargins(18, 14, 18, 18)
outer.setSpacing(12)
header = QHBoxLayout()
title = StrongBodyLabel("DLPilot", root)
# 标题字号走 QFont:FluentLabelBase 在每次主题切换时会用自带的颜色规则
# 重写自定义 qss 槽,setStyleSheet/setCustomStyleSheet 写的字号都会被冲掉
title_font = title.font()
title_font.setPixelSize(24)
title_font.setBold(True)
title.setFont(title_font)
header.addWidget(title)
header.addWidget(BodyLabel("EDID · 测试图案 · RS485 光机控制", root))
header.addStretch(1)
self.theme = ComboBox(root)
self.theme.addItems(["Dark", "Light", "System"])
self.theme.setCurrentText("System")
self.theme.currentTextChanged.connect(self._set_theme)
header.addWidget(self.theme)
header.addWidget(self._button("关于", self._show_about))
outer.addLayout(header)
split = QSplitter(Qt.Horizontal, root)
split.setChildrenCollapsible(False)
split.addWidget(self._scroll_column(self._build_left))
split.addWidget(self._scroll_column(self._build_right))
split.setStretchFactor(0, 1)
split.setStretchFactor(1, 1)
outer.addWidget(split, 1)
self.setCentralWidget(root)
def _scroll_column(self, builder):
host = QWidget()
layout = QVBoxLayout(host)
layout.setContentsMargins(2, 2, 8, 2)
layout.setSpacing(10)
builder(layout)
layout.addStretch(1)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
scroll.setWidget(host)
if builder.__name__ == '_build_left':
self.left_scroll = scroll
else:
self.right_scroll = scroll
return scroll
def _build_left(self, layout):
card, body = self._card("显示器选择", self)
row = QHBoxLayout()
self.cbo_display = ComboBox(card)
self.cbo_display.currentTextChanged.connect(self._on_select)
self.lbl_cur = BodyLabel("当前输出: —", card)
row.addWidget(self.cbo_display, 1)
row.addWidget(self._button("刷新", self._load_displays))
body.addLayout(row)
body.addWidget(self.lbl_cur)
layout.addWidget(card)
card, body = self._collapsible_card("支持的模式(EDID 解算)", self)
row = QHBoxLayout()
self.cbo_mode = ComboBox(card)
row.addWidget(BodyLabel("分辨率:", card))
row.addWidget(self.cbo_mode, 1)
row.addWidget(self._button("应用", self._apply_mode, True))
row.addWidget(self._button("恢复", self._restore_mode))
body.addLayout(row)
self.txt_modes = self._text()
self.txt_modes.setMinimumHeight(135)
body.addWidget(self.txt_modes)
layout.addWidget(card)
card, body = self._collapsible_card("EDID 信息", self)
self.txt_edid = self._text()
self.txt_edid.setMinimumHeight(150)
body.addWidget(self.txt_edid)
layout.addWidget(card)
card, body = self._card("测试图案", self)
row = QHBoxLayout()
for key, name in patterns.PATTERN_NAMES.items():
row.addWidget(self._button(name, lambda checked=False, k=key: self._show_pattern(k)))
row.addWidget(self._button("停止", close_qt_patterns))
body.addLayout(row)
area_row = QHBoxLayout()
area_row.addWidget(BodyLabel("测试卡区域:", card))
self.cbo_area = ComboBox(card)
self.cbo_area.addItems(list(config.AREA_OPTIONS))
self.cbo_area.setCurrentText(self.area)
self.cbo_area.currentTextChanged.connect(self._save_area)
area_row.addWidget(self.cbo_area)
area_row.addStretch(1)
body.addLayout(area_row)
for channel, items in patterns.SOLID_ROWS:
row = QHBoxLayout()
row.addWidget(BodyLabel(f"{channel}: 0x", card))
for label, color in items:
button = PushButton(label, card)
# fluent 控件的样式须走 setCustomStyleSheet:直接 setStyleSheet 的内容
# 会在 setTheme 时被库重刷的自带 qss 冲掉;且须覆盖 hover/pressed 态,
# 否则悬停瞬间底色仍会被自带态样式盖回
fg = 'black' if color == '#00FF00' else 'white'
qss = ("PushButton, PushButton:hover, PushButton:pressed, "
f"PushButton:disabled {{ background-color: {color}; color: {fg}; border: none; }}")
setCustomStyleSheet(button, qss, qss)
button.clicked.connect(lambda checked=False, c=color: self._show_solid(c))
row.addWidget(button)
row.addStretch(1)
body.addLayout(row)
self.slot_buttons = []
grid = QGridLayout()
for i in range(config.SLOT_COUNT):
cell = QWidget(card)
cell_layout = QHBoxLayout(cell)
cell_layout.setContentsMargins(0, 0, 0, 0)
button = self._button(self.slots[i]["name"], lambda checked=False, n=i: self._show_custom(n))
gear = self._button("⚙", lambda checked=False, n=i: self._configure_slot(n))
cell_layout.addWidget(button, 1)
cell_layout.addWidget(gear)
grid.addWidget(cell, i // 2, i % 2)
self.slot_buttons.append(button)
body.addLayout(grid)
layout.addWidget(card)
card, body = self._card("EDID 原始数据", self)
self.txt_hex = self._text()
self.txt_hex.setMinimumHeight(130)
body.addWidget(self.txt_hex)
body.addWidget(self._button("逐字节解析", self._show_edid_fields))
layout.addWidget(card)
def _build_right(self, layout):
if not dlpc_rs485.HAS_SERIAL:
card, body = self._card("光机控制不可用", self)
body.addWidget(BodyLabel("未安装 pyserial,请执行 py -m pip install pyserial。"))
layout.addWidget(card)
return
card, body = self._card("串口 · RS485 → GD32 网关 → DLPC3421", self)
row = QHBoxLayout()
self._dlpc_ports = dlpc_rs485.DLPC485.list_ports_info()
self.cbo_dlpc = ComboBox(card)
self.cbo_dlpc.addItems([label for _device, label in self._dlpc_ports])
if config.SERIAL_CFG["port"]:
self._dlpc_pick(config.SERIAL_CFG["port"])
row.addWidget(self.cbo_dlpc, 1)
row.addWidget(self._button("刷新", self._dlpc_refresh))
self.btn_dlpc = self._button("连接", self._dlpc_toggle, True)
row.addWidget(self.btn_dlpc)
body.addLayout(row)
params = QHBoxLayout()
for caption, attr, values, default in (
("波特率", "cbo_baud", ["115200", "57600", "38400", "9600"], str(config.SERIAL_CFG["baud"])),
("数据位", "cbo_databits", ["8", "7", "6", "5"], str(config.SERIAL_CFG["bytesize"])),
("校验", "cbo_parity", ["N", "E", "O"], str(config.SERIAL_CFG["parity"])),
("停止位", "cbo_stopbits", ["1", "2"], str(config.SERIAL_CFG["stopbits"])),
):
params.addWidget(BodyLabel(caption, card))
combo = ComboBox(card)
combo.addItems(values)
combo.setCurrentText(default)
setattr(self, attr, combo)
params.addWidget(combo)
self.lbl_dlpc_st = BodyLabel("未连接", card)
# 状态标签允许换行兜底: 任何长错误串都不得撑大参数行最小宽度(否则窗口拉长,
# 刷新/连接按钮被挤出可视区)
self.lbl_dlpc_st.setWordWrap(True)
params.addWidget(self.lbl_dlpc_st)
params.addStretch(1)
body.addLayout(params)
layout.addWidget(card)
card, body = self._card("串口数据 · 收发日志(译码 + 原始 hex)", self)
row = QHBoxLayout()
row.addStretch(1)
row.addWidget(self._button("清空", self._dlpc_clear))
body.addLayout(row)
self.txt_dlpc = self._text()
self.txt_dlpc.setMinimumHeight(230)
body.addWidget(self.txt_dlpc)
self._dlpc_log("帧格式: D5 01 LEN <命令> CRC / B5 TYPE LEN <payload> CRC")
layout.addWidget(card)
card, body = self._card("电源检查 · PD 自检 / 功率读数", self)
self.chip_pd = QLabel("PD", card)
self.chip_fpdo = QLabel("FPDO: —", card)
self.var_pwr = "电压: — 电流: — 功率: —"
self.box_pwr = QLabel(self.var_pwr, card)
for widget in (self.chip_pd, self.chip_fpdo, self.box_pwr):
widget.setMinimumHeight(30)
widget.setAlignment(Qt.AlignCenter | Qt.AlignVCenter)
widget.setStyleSheet(f"background: {CHIP_IDLE}; color: white; border-radius: 6px; padding: 4px 8px;")
row = QHBoxLayout()
row.addWidget(self._button("电源自检", self._pd_selftest))
row.addWidget(self.chip_pd)
row.addWidget(self.chip_fpdo, 1)
body.addLayout(row)
row = QHBoxLayout()
row.addWidget(self._button("功率检测", self._pwr_query))
row.addWidget(self.box_pwr, 1)
body.addLayout(row)
layout.addWidget(card)
card, body = self._card("光机命令 · 预设 / 自定义 / 温度", self)
row = QHBoxLayout()
self.var_current = BodyLabel("电流: —", card)
row.addWidget(self.var_current)
row.addStretch(1)
row.addWidget(self._button("查电流", self._query_current))
body.addLayout(row)
preset_row = QHBoxLayout()
self._dlpc_presets = [(n, cmd) for n, cmd, _ in dlpc_rs485.PRESETS]
self.cbo_dlpc_cmd = ComboBox(card)
self.cbo_dlpc_cmd.addItems([f"{n} {cmd}" for n, cmd in self._dlpc_presets])
preset_row.addWidget(BodyLabel("预设:", card))
preset_row.addWidget(self.cbo_dlpc_cmd, 1)
preset_row.addWidget(self._button("发送", self._dlpc_send_preset, True))
preset_row.addWidget(self._button("查 DMD 温度", self._query_dmd))
self.var_dmd = BodyLabel("DMD: —", card)
preset_row.addWidget(self.var_dmd)
body.addLayout(preset_row)
custom_row = QHBoxLayout()
self.var_custom_cmd = LineEdit(card)
self.var_custom_cmd.setPlaceholderText("如 M730S0T2000")
self.var_custom_cmd.returnPressed.connect(self._dlpc_send_custom)
custom_row.addWidget(BodyLabel("自定义:", card))
custom_row.addWidget(self.var_custom_cmd, 1)
custom_row.addWidget(self._button("发送", self._dlpc_send_custom))
custom_row.addWidget(self._button("查 LAMP 温度", self._query_lamp))
self.var_lamp = BodyLabel("LAMP: —", card)
custom_row.addWidget(self.var_lamp)
body.addLayout(custom_row)
layout.addWidget(card)
card, body = self._card("快捷滑条 · 光强 / 定时投光 / TEC 温度", self)
self.sld_light, self.ent_light = self._slider_row(body, "光强 (54-800)", 54, 800, 100, self._dlpc_send_light, "下发电流值")
self.sld_timer, self.ent_timer = self._slider_row(body, "定时投光 (毫秒)", 0, 32700, 2000, self._dlpc_send_timer, "开光机(定时)")
self.sld_tec, self.ent_tec = self._slider_row(body, "TEC 凝露点 (0.1°C)", 200, 350, 330, self._dlpc_send_tec, "下发温度值")
layout.addWidget(card)
def _slider_row(self, layout, text, low, high, value, callback, button_text):
row = QHBoxLayout()
row.addWidget(BodyLabel(text, self))
slider = Slider(Qt.Horizontal, self)
slider.setRange(low, high)
slider.setValue(value)
entry = LineEdit(self)
entry.setText(str(value))
entry.setFixedWidth(65)
slider.valueChanged.connect(lambda v: entry.setText(str(v)))
entry.returnPressed.connect(lambda: self._sync_slider(slider, entry, low, high))
row.addWidget(slider, 1)
row.addWidget(entry)
row.addWidget(self._button(button_text, callback))
layout.addLayout(row)
return slider, entry
@staticmethod
def _sync_slider(slider, entry, low, high):
try:
value = int(entry.text())
except ValueError:
value = slider.value()
value = max(low, min(high, value))
slider.setValue(value)
entry.setText(str(value))
def _set_theme(self, value):
app = QApplication.instance()
setTheme({"Dark": Theme.DARK, "Light": Theme.LIGHT, "System": Theme.AUTO}[value])
if value == "System":
app.setPalette(self._system_palette)
app.setStyleSheet(self._system_style)
is_dark = app.palette().color(QPalette.Window).value() < 128
else:
is_dark = value == "Dark"
self._apply_palette(is_dark)
self._apply_native_titlebar(is_dark)
def _apply_palette(self, is_dark):
"""Keep native Qt widgets and Fluent cards on the same surface colors."""
app = QApplication.instance()
palette = QPalette()
if is_dark:
window, base, text, border = "#202020", "#2b2b2b", "#f5f5f5", "#3a3a3a"
accent = "#00c8d7"
else:
window, base, text, border = "#f4f6f8", "#ffffff", "#1f2328", "#d8dee4"
accent = "#0f6cbd"
for role in (QPalette.Window, QPalette.Base, QPalette.AlternateBase):
palette.setColor(role, QColor(window if role == QPalette.Window else base))
palette.setColor(QPalette.Text, QColor(text))
palette.setColor(QPalette.WindowText, QColor(text))
palette.setColor(QPalette.Button, QColor(base))
palette.setColor(QPalette.ButtonText, QColor(text))
palette.setColor(QPalette.PlaceholderText, QColor("#8b949e" if not is_dark else "#8a8a8a"))
palette.setColor(QPalette.Highlight, QColor(accent))
palette.setColor(QPalette.HighlightedText, QColor("#ffffff"))
palette.setColor(QPalette.ToolTipBase, QColor(base))
palette.setColor(QPalette.ToolTipText, QColor(text))
app.setPalette(palette)
app.setStyleSheet(f"""
QMainWindow, QWidget#root {{ background-color: {window}; color: {text}; }}
QScrollArea, QScrollArea > QWidget > QWidget {{ background: transparent; border: 0; }}
CardWidget {{ background-color: {base}; border: 1px solid {border}; border-radius: 8px; }}
QPlainTextEdit, QTextEdit, QLineEdit, QComboBox {{
background-color: {base}; color: {text};
border: 1px solid {border}; border-radius: 5px;
selection-background-color: {accent};
}}
QToolButton {{ color: {text}; }}
""")
def _apply_native_titlebar(self, is_dark):
if os.name != "nt" or not hasattr(ctypes, "windll"):
return
try:
hwnd = int(self.winId())
dwm = ctypes.windll.dwmapi
dark = ctypes.c_int(1 if is_dark else 0)
dwm.DwmSetWindowAttribute(hwnd, 20, ctypes.byref(dark), ctypes.sizeof(dark))
color = QColor("#202020" if is_dark else "#f4f6f8")
colorref = ctypes.c_int(color.red() | (color.green() << 8) | (color.blue() << 16))
text = QColor("#f5f5f5" if is_dark else "#1f2328")
textref = ctypes.c_int(text.red() | (text.green() << 8) | (text.blue() << 16))
dwm.DwmSetWindowAttribute(hwnd, 35, ctypes.byref(colorref), ctypes.sizeof(colorref))
dwm.DwmSetWindowAttribute(hwnd, 36, ctypes.byref(textref), ctypes.sizeof(textref))
except Exception:
pass
def _cur_display(self):
try:
return self.displays[self._disp_labels.index(self.cbo_display.currentText())]
except (ValueError, IndexError, AttributeError):
return None
def _load_displays(self):
previous = self._cur_label
try:
self.displays = display_win.get_displays()
except Exception as exc:
self.displays = []
self.notice("显示器枚举失败", str(exc), error=True)
self._disp_labels = [f"{d['device']} {d['adapter']} [{d['mon_name']}]" for d in self.displays]
self.cbo_display.blockSignals(True)
self.cbo_display.clear()
self.cbo_display.addItems(self._disp_labels)
if self._disp_labels:
self.cbo_display.setCurrentText(previous if previous in self._disp_labels else self._disp_labels[0])
self.cbo_display.blockSignals(False)
if self._disp_labels:
self._cur_label = self.cbo_display.currentText()
self._on_select(self._cur_label)
self.left_scroll.verticalScrollBar().setValue(0)
self.right_scroll.verticalScrollBar().setValue(0)
else:
self.lbl_cur.setText("未找到活动显示器")
self.txt_modes.clear(); self.txt_edid.clear(); self.txt_hex.clear()
def _on_select(self, _value=None):
d = self._cur_display()
if d is None:
return
self._cur_label = self.cbo_display.currentText()
self.lbl_cur.setText(f"当前输出: {d['cur_mode']}")
keys, self._modes = display_win.enum_modes(d['device'])
self.cbo_mode.clear(); self.cbo_mode.addItems(keys)
if d['cur_mode'] in self._modes:
self.cbo_mode.setCurrentText(d['cur_mode'])
if not d['edid']:
self.txt_edid.setPlainText("EDID 读取失败(注册表无 EDID 数据)")
self.txt_modes.clear(); self.txt_hex.clear(); return
parsed = edid_parse.parse_edid(d['edid'])
self.txt_edid.setPlainText("\n".join(f"{k:<16}{v}" for k, v in edid_parse.edid_info_lines(parsed)))
lines = [f"{t['hact']}x{t['vact']} @{t['refresh']:g}Hz {t['clk_khz'] / 1000:.2f}MHz {'DTD 首选' if i == 0 else 'DTD'}" for i, t in enumerate(parsed['dtds'])]
lines += [f"{m} [CEA-VIC]" for m in parsed['cea_video']]
lines += [f"{m} [已建立时序]" for m in parsed['est']]
lines += [f"{m} [标准时序]" for m in parsed['std']]
self.txt_modes.setPlainText("\n".join(lines))
self.txt_hex.setPlainText(edid_parse.hex_dump(d['edid']))
def _apply_mode(self):
d = self._cur_display()
if d is None or self.cbo_mode.currentText() not in self._modes:
return
device = d['device']
if device not in self._orig_modes:
current = display_win.DEVMODEW(); current.dmSize = ctypes.sizeof(current)
if display_win.user32.EnumDisplaySettingsW(device, display_win.ENUM_CURRENT_SETTINGS, ctypes.byref(current)):
self._orig_modes[device] = current
ok, result = display_win.apply_mode(device, self._modes[self.cbo_mode.currentText()])
if not ok:
self.notice("切换失败", f"ChangeDisplaySettingsEx 返回码 {result}", error=True)
return
QTimer.singleShot(500, self._load_displays)
def _restore_mode(self):
d = self._cur_display()
if d is None: return
mode = self._orig_modes.pop(d['device'], None)
if mode is None:
self.notice("无可恢复", "该显示器没有记录切改前的模式")
return
display_win.apply_mode(d['device'], mode)
QTimer.singleShot(500, self._load_displays)
def _show_pattern(self, kind):
d = self._cur_display()
if d and d['rect']:
close_qt_patterns(); PatternWindow(d['rect'], kind, self.cbo_area.currentText(), parent=self)
def _show_solid(self, color):
self._show_pattern(color)
def _save_area(self, value):
self.area = value
config.save_slots(self.slots, value)
def _show_custom(self, index):
slot = self.slots[index]
if not slot['file']:
self.notice("未配置图片", f"请先配置“{slot['name']}”的图片")
return
path = os.path.join(config.BASE_DIR, slot['file'])
d = self._cur_display()
if not os.path.exists(path):
self.notice("图片不存在", path, error=True); return
if d and d['rect']:
close_qt_patterns(); PatternWindow(d['rect'], "", self.cbo_area.currentText(), path, self)
def _configure_slot(self, index):
dialog = QDialog(self); dialog.setWindowTitle(f"配置自定义栏位 {index + 1}")
form = QFormLayout(dialog)
name = LineEdit(dialog); name.setText(self.slots[index]['name'])
file_label = BodyLabel(self.slots[index]['file'] or "(未设置)", dialog)
form.addRow("按钮名称", name); form.addRow("图片文件", file_label)
buttons = QHBoxLayout(); choose = PushButton("选择图片...", dialog); clear = PushButton("清除图片", dialog); save = PrimaryPushButton("保存", dialog); cancel = PushButton("取消", dialog)
buttons.addWidget(choose); buttons.addWidget(clear); buttons.addStretch(1); buttons.addWidget(save); buttons.addWidget(cancel); form.addRow(buttons)
selected = [self.slots[index]['file']]
def choose_file():
path, _ = QFileDialog.getOpenFileName(dialog, "选择图片", config.BASE_DIR, f"图片文件 ({' '.join('*' + e for e in config.IMG_EXTS)})")
if path:
try: selected[0] = config.copy_image_into_script_dir(path); file_label.setText(selected[0])
except OSError as exc: self.notice("复制图片失败", str(exc), error=True)
choose.clicked.connect(choose_file); clear.clicked.connect(lambda: (selected.__setitem__(0, None), file_label.setText("(未设置)")))
cancel.clicked.connect(dialog.reject)
def save_slot():
self.slots[index] = {'name': name.text().strip() or f'自定义{index + 1}', 'file': selected[0]}
config.save_slots(self.slots, self.cbo_area.currentText()); self.slot_buttons[index].setText(self.slots[index]['name']); dialog.accept()
save.clicked.connect(save_slot); dialog.exec()
def _show_about(self):
QMessageBox.about(self, f"关于 {APP_NAME}", f"{APP_NAME} v{APP_VERSION}\n\nEDID / 测试图案 / RS485 光机控制\nPySide6-Fluent-Widgets UI\n\n{REPO_URL}")
def _show_edid_fields(self):
d = self._cur_display()
if d is None or not d['edid']:
self.notice("无 EDID", "该显示器没有可解析的 EDID 数据"); return
dialog = QDialog(self); dialog.setWindowTitle("EDID 逐字节解析"); dialog.resize(1120, 700)
layout = QVBoxLayout(dialog); text = self._text(); text.setLineWrapMode(QPlainTextEdit.WidgetWidth)
lines = []
for off, hx, meaning, value, note in edid_parse.edid_field_rows(d['edid']):
lines.append(f"{off} {meaning} {value}")
if hx: lines.append(f" 原始: {hx}")
if note: lines.append(f" {note}")
text.setPlainText("\n".join(lines)); layout.addWidget(text); layout.addWidget(self._button("关闭", dialog.accept)); dialog.exec()
# ---------------- RS485 ----------------
def _dlpc_on_frame(self, frame):
if self._pd_seq: self._dlpc_log_q.put(('pdframe', frame))
elif self._pwr_seq: self._dlpc_log_q.put(('pwrframe', frame))
def _dlpc_on_event(self, line):
match = re.search(r'← okTL:(-?\d+)', line)
if match and self._tl_order:
which = self._tl_order.pop(0); self._dlpc_log_q.put(('temp', f"{which}: {int(match.group(1)) / 10:.1f}°C"))
match = re.search(r'← okR(\d+)G(\d+)B(\d+)', line)
if match: self._dlpc_log_q.put(('current', f"电流 R:{match.group(1)} G:{match.group(2)} B:{match.group(3)}"))
self._dlpc_log_q.put(('event', line))
def _dlpc_on_status(self, message): self._dlpc_log_q.put(('status', message))
def _query_current(self): self._dlpc_send('M732')
def _query_dmd(self): self._tl_order.append('DMD'); self._dlpc_send('M201')
def _query_lamp(self): self._tl_order.append('LAMP'); self._dlpc_send('M202')
def _poll_dlpc(self):
try:
while True:
kind, value = self._dlpc_log_q.get_nowait()
if kind == 'status':
self.lbl_dlpc_st.setText(value)
if value.startswith(('已断开', '串口丢失')): self._serial_reset_state()
elif kind == 'temp': (self.var_dmd if value.startswith('DMD') else self.var_lamp).setText(value)
elif kind == 'current': self.var_current.setText(value)
elif kind == 'pdframe': self._pd_step_done(value.type_code == 0xF0, value)
elif kind == 'pwrframe': self._pwr_step_done(value)
else: self._dlpc_log(value)
except queue.Empty: pass
connected = self.dlpc.is_connected()
if connected != self._dlpc_connected:
self._dlpc_connected = connected; self.btn_dlpc.setText("断开" if connected else "连接")
def _dlpc_log(self, line):
self.txt_dlpc.appendPlainText(time.strftime('%H:%M:%S ') + line)
if self.txt_dlpc.blockCount() > 500:
cursor = self.txt_dlpc.textCursor(); cursor.movePosition(cursor.Start); cursor.select(cursor.BlockUnderCursor); cursor.removeSelectedText(); cursor.deleteChar()
self.txt_dlpc.verticalScrollBar().setValue(self.txt_dlpc.verticalScrollBar().maximum())
def _dlpc_clear(self): self.txt_dlpc.clear()
def _dlpc_refresh(self):
self._dlpc_ports = dlpc_rs485.DLPC485.list_ports_info(); self.cbo_dlpc.clear(); self.cbo_dlpc.addItems([label for _d, label in self._dlpc_ports])
if self._dlpc_ports: self._dlpc_pick(config.SERIAL_CFG['port'] or self._dlpc_ports[0][0])
def _dlpc_pick(self, device):
for dev, label in self._dlpc_ports:
if dev == device: self.cbo_dlpc.setCurrentText(label); return
def _dlpc_selected_port(self):
label = self.cbo_dlpc.currentText()
for device, shown in self._dlpc_ports:
if shown == label: return device
return label.strip()
def _dlpc_toggle(self):
if self.dlpc.is_connected(): self.dlpc.close(); self._serial_reset_state(); return
port = self._dlpc_selected_port()
if not port: self.lbl_dlpc_st.setText('未选择串口,请先选择或刷新'); return
try: baud, bytesize, stopbits = int(self.cbo_baud.currentText()), int(self.cbo_databits.currentText()), int(self.cbo_stopbits.currentText())
except ValueError: self.lbl_dlpc_st.setText("串口参数非法"); return
parity = self.cbo_parity.currentText()[:1] or 'N'; config.SERIAL_CFG.update(port=port, baud=baud, bytesize=bytesize, parity=parity, stopbits=stopbits); config.save_slots(self.slots, self.cbo_area.currentText()); self.dlpc.open(port, baud, bytesize=bytesize, parity=parity, stopbits=stopbits)
def _serial_reset_state(self):
self._pd_seq.clear(); self._pwr_seq.clear(); self._tl_order.clear()
for timer in (self._pd_timer, self._pwr_timer):
if timer: timer.stop()
self._pd_timer = self._pwr_timer = None
def _dlpc_send(self, command):
if not self.dlpc.is_connected(): self.lbl_dlpc_st.setText('未连接串口'); return
if self._pd_seq or self._pwr_seq: self._dlpc_log(f'序列进行中, 命令已忽略: {command}'); return
self.dlpc.send(command)
def _dlpc_send_preset(self):
label = self.cbo_dlpc_cmd.currentText()
for name, command in self._dlpc_presets:
if label.startswith(name + ' '): self._dlpc_send(command); return
def _dlpc_send_custom(self):
if self.var_custom_cmd.text().strip(): self._dlpc_send(self.var_custom_cmd.text().strip())
def _pd_selftest(self):
if not self.dlpc.is_connected(): self.lbl_dlpc_st.setText('未连接串口'); return
if self._pd_seq: return
self._pd_seq = [('M301', 'pd'), ('M302', 'fpdo_ma'), ('M303', 'meas_mv')]; self._pd_values = {}; self._pd_chip_reset(); self._dlpc_log('电源自检: 开始'); self._pd_step_next()
def _pd_chip_reset(self):
self.chip_pd.setText('PD'); self.chip_pd.setStyleSheet(f'background: {CHIP_IDLE}; color: white; border-radius: 6px; padding: 4px 8px;'); self.chip_fpdo.setText('FPDO: —'); self.chip_fpdo.setStyleSheet(f'background: {CHIP_IDLE}; color: white; border-radius: 6px; padding: 4px 8px;')
def _pd_step_next(self):
if not self._pd_seq: self._pd_fpdo_verdict(); self._dlpc_log('电源自检: 完成'); return
self.dlpc.send(self._pd_seq[0][0]); self._pd_timer = QTimer(self); self._pd_timer.setSingleShot(True); self._pd_timer.timeout.connect(lambda: self._pd_step_done(False, None)); self._pd_timer.start(3000)
def _pd_step_done(self, ok, frame):
if not self._pd_seq: return
if self._pd_timer: self._pd_timer.stop(); self._pd_timer = None
_command, kind = self._pd_seq.pop(0); raw = dlpc_rs485.payload_bytes(frame) if frame is not None else b''
if ok and kind == 'pd':
proto = raw.decode('ascii', 'replace').strip()
good = proto in ('PD', 'EPR')
self.chip_pd.setText(proto or 'PD')
self.chip_pd.setStyleSheet(
f'background: {CHIP_OK if good else CHIP_ERR}; color: white; '
'border-radius: 6px; padding: 4px 8px;')
elif ok and kind == 'fpdo_ma' and len(raw) == 2: self._pd_values['ma'] = dlpc_rs485.payload_u16_be(raw)
elif ok and kind == 'meas_mv' and len(raw) == 2: self._pd_values['mv'] = dlpc_rs485.payload_u16_be(raw)
if ok: self._pd_step_next()
else: self._pd_seq.clear(); self._pd_chip_error(kind); self._dlpc_log('电源自检: 应答错误/超时, 序列中止')
def _pd_chip_error(self, kind):
widget = self.chip_pd if kind == 'pd' else self.chip_fpdo
if kind == 'pd': widget.setText('ERR')
else: widget.setText('FPDO: ERR')
widget.setStyleSheet(f'background: {CHIP_ERR}; color: white; border-radius: 6px; padding: 4px 8px;')
def _pd_fpdo_verdict(self):
mv, ma = self._pd_values.get('mv'), self._pd_values.get('ma')
if mv is None or ma is None: self._pd_chip_error('fpdo_ma'); return
gear = next((g for g in (5, 9, 12, 15, 20, 28) if abs(mv - g * 1000) <= g * 50), None); text = 'FPDO: ERR' if gear is None else f'FPDO: {gear}V@{ma / 1000:g}A'; self.chip_fpdo.setText(text); good = gear == 12 and ma >= 3000; self.chip_fpdo.setStyleSheet(f'background: {CHIP_OK if good else CHIP_ERR}; color: white; border-radius: 6px; padding: 4px 8px;')
def _pwr_query(self):
if not self.dlpc.is_connected(): self.lbl_dlpc_st.setText('未连接串口'); return
if self._pwr_seq: return
self._pwr_seq = [('M303', 'mv'), ('M304', 'ma'), ('M305', 'mw')]; self._pwr_values = {}; self._dlpc_log('功率检测: 开始'); self._pwr_step_next()
def _pwr_step_next(self):
if not self._pwr_seq: self._pwr_update(self._pwr_values.get('mv'), self._pwr_values.get('ma'), self._pwr_values.get('mw')); self._dlpc_log('功率检测: 完成'); return
self.dlpc.send(self._pwr_seq[0][0]); self._pwr_timer = QTimer(self); self._pwr_timer.setSingleShot(True); self._pwr_timer.timeout.connect(lambda: self._pwr_step_done(None)); self._pwr_timer.start(3000)
def _pwr_step_done(self, frame):
if not self._pwr_seq: return
if self._pwr_timer: self._pwr_timer.stop(); self._pwr_timer = None
_command, kind = self._pwr_seq.pop(0); raw = dlpc_rs485.payload_bytes(frame) if frame is not None else b''; want = {'mv': 2, 'ma': 2, 'mw': 4}[kind]
if frame is None or frame.type_code != 0xF0 or len(raw) != want: self._pwr_seq.clear(); self.box_pwr.setText('电压: ERR 电流: — 功率: —'); return
self._pwr_values[kind] = {'mv': dlpc_rs485.payload_u16_be, 'ma': dlpc_rs485.payload_s16_be, 'mw': dlpc_rs485.payload_u32_be}[kind](raw); self._pwr_step_next()
def _pwr_update(self, mv, ma, mw):
voltage = f'{mv / 1000:.2f} V' if mv is not None else '—'
current = f'{ma} mA' if ma is not None else '—'
power = f'{mw / 1000:.2f} W' if mw is not None else '—'
self.box_pwr.setText(f'电压: {voltage} 电流: {current} 功率: {power}')
def _dlpc_send_light(self): self._sync_slider(self.sld_light, self.ent_light, 54, 800); self._dlpc_send(f'M731S{self.sld_light.value()}')
def _dlpc_send_timer(self): self._sync_slider(self.sld_timer, self.ent_timer, 0, 32700); self._dlpc_send('M730S0' if self.sld_timer.value() <= 0 else f'M730S0T{self.sld_timer.value()}')
def _dlpc_send_tec(self): self._sync_slider(self.sld_tec, self.ent_tec, 200, 350); self._dlpc_send(f'M8S{self.sld_tec.value()}')
def notice(self, title, text, error=False):
box = QMessageBox(self); box.setWindowTitle(title); box.setText(text); box.setIcon(QMessageBox.Critical if error else QMessageBox.Information); box.exec()
def closeEvent(self, event):
close_qt_patterns()
self._serial_reset_state()
try: self.dlpc.close()
except Exception: pass
super().closeEvent(event)
def run_gui(auto_close=0):
if not _single_instance():
app = QApplication.instance() or QApplication(sys.argv)
QMessageBox.information(None, "DLPilot 已在运行", "检测到另一个 DLPilot 实例正在运行。")
return
app = QApplication.instance() or QApplication(sys.argv)
app.setApplicationName(APP_NAME)
setTheme(Theme.AUTO)
window = App(auto_close)
window.show()
app.exec()
File diff suppressed because it is too large Load Diff
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
把 hdmi_gui.py 打包成单文件 exe(工具名: DLPilot)
================================
使用 PyInstaller --onefile --noconsole: Python 解释器、PySide6 和
PySide6-Fluent-Widgets 全部打进一个 exe, 目标机器无需安装依赖。
用法:
python build_exe.py 自动安装 PyInstaller(如缺)并打包
python build_exe.py --debug 额外打一个带控制台的变体(--dump 可用)
产物: release/DLPilot.exe(及 --debug 时的 DLPilot-console.exe)
首次运行 onefile exe 会先解压到临时目录, 启动比脚本慢一两秒, 属正常现象。
"""
import os
import subprocess
import sys
# 本脚本位于 src/, 打包产物输出到工具根的 release/(与 pattern_slots.json 同目录)
SRC = os.path.dirname(os.path.abspath(__file__))
BASE = os.path.dirname(SRC)
ICO = os.path.join(SRC, 'logos', 'logo_dlpilot.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 extra_collect_args():
"""Fluent Widgets 的主题资源必须收集; PyInstaller 自带 Qt hooks 会处理 PySide6。"""
try:
import qfluentwidgets # noqa: F401
except ImportError:
print('警告: 未安装 PySide6-Fluent-Widgets, 请先执行 pip install PySide6-Fluent-Widgets')
return []
return ['--collect-all', 'qfluentwidgets']
def qt_icu_args():
"""Qt6Core imports the unversioned Windows ICU API. PyInstaller can
otherwise pick Poppler's ICU 78 from PATH, whose suffixed exports make
QtCore.pyd fail with WinError 127 on startup."""
if os.name == 'nt' and os.path.exists(r'C:\Windows\System32\icuuc.dll'):
return ['--add-binary', r'C:\Windows\System32\icuuc.dll;.']
return []
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, 'release')
work = os.path.join(SRC, '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 + extra_collect_args() + qt_icu_args() + [os.path.join(SRC, 'hdmi_gui.py')]
if os.path.exists(ICO):
# exe 文件图标 + 运行时窗口图标资源(从 _MEIPASS 读取)
cmd += ['--icon', ICO, '--add-data', f'{ICO};.']
else:
print('警告: 未找到 logos/logo_dlpilot.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('DLPilot', ['--noconsole'])
if build_console:
# 调试版: 带控制台, --dump / 异常栈可见
build('DLPilot-console', [])
if __name__ == '__main__':
main()
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
工具运行配置: 运行目录定位 / pattern_slots.json(自定义栏位 + 串口记忆)
================================================================
PyInstaller onefile 打包后 __file__ 指向临时解压目录,
配置必须落在 exe 旁边, 故 frozen 时改用 sys.executable 定位。
"""
import json
import os
import shutil
import sys
import dlpc_rs485
# 可选 Pillow: 装了则支持 JPEG/BMP/WebP, 没装则用 tkinter 原生(仅 PNG/GIF)
# Image/ImageTk 在此导入一次, patterns.py 复用同一份对象
try:
from PIL import Image, ImageTk # noqa: F401
HAS_PIL = True
except ImportError:
Image = ImageTk = None
HAS_PIL = False
# 测试卡区域常量(栏位配置与投屏共用)
PAT_W, PAT_H = 640, 360
AREA_OPTIONS = ('640x360', '800x600', 'full')
DEFAULT_AREA = AREA_OPTIONS[0]
# ============================================================
# 自定义图案栏位: 配置存于脚本目录 pattern_slots.json,
# 图片文件同样保存在脚本目录下
# 注: PyInstaller onefile 打包后 __file__ 指向临时解压目录,
# 配置必须落在 exe 旁边, 故 frozen 时改用 sys.executable 定位
# ============================================================
if getattr(sys, 'frozen', False):
BASE_DIR = os.path.dirname(os.path.abspath(sys.executable))
else:
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
SLOT_COUNT = 10
SLOT_CONFIG = os.path.join(BASE_DIR, 'pattern_slots.json')
IMG_EXTS = ['.png', '.gif'] + (['.jpg', '.jpeg', '.bmp', '.webp'] if HAS_PIL else [])
# 光机控制串口记忆(与栏位配置同存 pattern_slots.json 的 "serial" 键)
SERIAL_CFG = {'port': '', 'baud': dlpc_rs485.DEFAULT_BAUD,
'bytesize': 8, 'parity': 'N', 'stopbits': 1}
def load_slots():
"""读 pattern_slots.json, 返回 (slots, area)。
新格式为 {'area': 'WxH'|'full', 'slots': [...], 'serial': {...}};
兼容旧版纯数组格式(无 area)与无 serial 键的中间格式。
"""
slots = [{'name': f'自定义{i + 1}', 'file': None} for i in range(SLOT_COUNT)]
try:
with open(SLOT_CONFIG, encoding='utf-8') as f:
data = json.load(f)
except Exception:
return slots, DEFAULT_AREA
if isinstance(data, dict):
area = str(data.get('area') or DEFAULT_AREA)
items = data.get('slots') or []
ser = data.get('serial')
if isinstance(ser, dict):
SERIAL_CFG['port'] = str(ser.get('port') or '')
try:
SERIAL_CFG['baud'] = int(ser.get('baud') or dlpc_rs485.DEFAULT_BAUD)
except (TypeError, ValueError):
pass
try:
SERIAL_CFG['bytesize'] = int(ser.get('bytesize') or 8)
except (TypeError, ValueError):
pass
SERIAL_CFG['parity'] = str(ser.get('parity') or 'N').upper()[:1]
try:
SERIAL_CFG['stopbits'] = int(ser.get('stopbits') or 1)
except (TypeError, ValueError):
pass
else:
area, items = DEFAULT_AREA, data
for i, item in enumerate(items[:SLOT_COUNT]):
if isinstance(item, dict):
slots[i]['name'] = str(item.get('name') or slots[i]['name'])
slots[i]['file'] = item.get('file') or None
return slots, area
def save_slots(slots, area):
with open(SLOT_CONFIG, 'w', encoding='utf-8') as f:
json.dump({'area': area, 'slots': slots, 'serial': SERIAL_CFG},
f, ensure_ascii=False, indent=2)
def copy_image_into_script_dir(src_path):
"""把任意路径的图片复制到脚本目录, 重名自动加序号, 返回文件名"""
dst_name = os.path.basename(src_path)
dst = os.path.join(BASE_DIR, dst_name)
if os.path.normcase(os.path.abspath(src_path)) == os.path.normcase(dst):
return dst_name
stem, ext = os.path.splitext(dst_name)
n = 1
while os.path.exists(dst):
dst_name = f'{stem}_{n}{ext}'
dst = os.path.join(BASE_DIR, dst_name)
n += 1
shutil.copy2(src_path, dst)
return dst_name
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Win32 显示器枚举 / 分辨率切换 / 显示拓扑(纯 ctypes, 无第三方依赖)
================================================================
get_displays() 枚举活动显示器(设备名/显卡/监视器/当前模式/矩形/EDID)
enum_modes() 显卡按该屏 EDID 提供的模式列表
apply_mode() 临时切换分辨率(不写注册表, 重启还原)
ensure_display_extend() 智能拓扑: 仅转接板(EDID 厂商 XLS)接入但未进桌面时强制扩展
"""
import ctypes
import ctypes.wintypes as wt
import winreg
# ============================================================
# Win32 显示 API
# ============================================================
user32 = ctypes.windll.user32
ENUM_CURRENT_SETTINGS = 0xFFFFFFFF # (DWORD)-1
class DISPLAY_DEVICEW(ctypes.Structure):
_fields_ = [
('cb', wt.DWORD),
('DeviceName', ctypes.c_wchar * 32),
('DeviceString', ctypes.c_wchar * 128),
('StateFlags', wt.DWORD),
('DeviceID', ctypes.c_wchar * 128),
('DeviceKey', ctypes.c_wchar * 128),
]
class DEVMODEW(ctypes.Structure):
_fields_ = [
('dmDeviceName', ctypes.c_wchar * 32),
('dmSpecVersion', wt.WORD),
('dmDriverVersion', wt.WORD),
('dmSize', wt.WORD),
('dmDriverExtra', wt.WORD),
('dmFields', wt.DWORD),
('dmPositionX', wt.LONG),
('dmPositionY', wt.LONG),
('dmDisplayOrientation', wt.DWORD),
('dmDisplayFixedOutput', wt.DWORD),
('dmColor', wt.WORD),
('dmDuplex', wt.WORD),
('dmYResolution', wt.WORD),
('dmTTOption', wt.WORD),
('dmCollate', wt.WORD),
('dmFormName', ctypes.c_wchar * 32),
('dmLogPixels', wt.WORD),
('dmBitsPerPel', wt.DWORD),
('dmPelsWidth', wt.DWORD),
('dmPelsHeight', wt.DWORD),
('dmDisplayFlags', wt.DWORD),
('dmDisplayFrequency', wt.DWORD),
('dmICMMethod', wt.DWORD),
('dmICMIntent', wt.DWORD),
('dmMediaType', wt.DWORD),
('dmDitherType', wt.DWORD),
('dmReserved1', wt.DWORD),
('dmReserved2', wt.DWORD),
('dmPanningWidth', wt.DWORD),
('dmPanningHeight', wt.DWORD),
]
user32.EnumDisplayDevicesW.argtypes = [ctypes.c_wchar_p, wt.DWORD,
ctypes.POINTER(DISPLAY_DEVICEW), wt.DWORD]
user32.EnumDisplayDevicesW.restype = wt.BOOL
user32.EnumDisplaySettingsW.argtypes = [ctypes.c_wchar_p, wt.DWORD,
ctypes.POINTER(DEVMODEW)]
user32.EnumDisplaySettingsW.restype = wt.BOOL
user32.ChangeDisplaySettingsExW.argtypes = [ctypes.c_wchar_p, ctypes.POINTER(DEVMODEW),
ctypes.c_void_p, wt.DWORD, ctypes.c_void_p]
user32.ChangeDisplaySettingsExW.restype = ctypes.c_long
DM_BITSPERPEL = 0x00040000
DM_PELSWIDTH = 0x00080000
DM_PELSHEIGHT = 0x00100000
DM_DISPLAYFREQUENCY = 0x00400000
def read_edid_from_registry(device_id):
"""monitor DeviceID 'MONITOR\\<pnp>\\<inst>' → 注册表 EDID 原始字节
注意: 间接显示器/虚拟显示驱动返回的 DeviceID 实例段是 '{类GUID}\\NNNN'
格式, 注册表中并不存在该路径; 因此精确匹配失败后按 PNP ID 枚举其下
全部实例, 取第一份 EDID(同一物理屏的重复实例 EDID 相同)。
"""
if not device_id or not device_id.startswith('MONITOR\\'):
return None
parts = device_id.split('\\')
if len(parts) < 3:
return None
pnp = parts[1]
inst = device_id.split('\\', 2)[2]
def _read(path):
try:
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path)
try:
val, _typ = winreg.QueryValueEx(key, 'EDID')
if isinstance(val, (bytes, bytearray)):
return bytes(val)
if isinstance(val, list): # 某些系统返回 int 列表
return bytes(val)
finally:
winreg.CloseKey(key)
except OSError:
pass
return None
# 1) 精确实例路径(常规真实显示器)
raw = _read(rf'SYSTEM\CurrentControlSet\Enum\DISPLAY\{pnp}\{inst}\Device Parameters')
if raw:
return raw
# 2) 按 PNP ID 遍历全部实例(间接显示器/实例名不一致)
try:
pnp_root = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
rf'SYSTEM\CurrentControlSet\Enum\DISPLAY\{pnp}')
except OSError:
return None
try:
for i in range(winreg.QueryInfoKey(pnp_root)[0]):
sub = winreg.EnumKey(pnp_root, i)
raw = _read(rf'SYSTEM\CurrentControlSet\Enum\DISPLAY\{pnp}\{sub}\Device Parameters')
if raw:
return raw
finally:
winreg.CloseKey(pnp_root)
return None
def get_displays():
"""枚举活动显示器: 设备名/显卡/监视器名/当前模式/屏幕矩形/EDID 字节"""
out = []
i = 0
while True:
dd = DISPLAY_DEVICEW()
dd.cb = ctypes.sizeof(dd)
if not user32.EnumDisplayDevicesW(None, i, ctypes.byref(dd), 0):
break
if dd.StateFlags & 0x1: # ATTACHED_TO_DESKTOP
# 找该输出口上活动的 monitor 条目
mon = None
j = 0
while True:
m = DISPLAY_DEVICEW()
m.cb = ctypes.sizeof(m)
if not user32.EnumDisplayDevicesW(dd.DeviceName, j, ctypes.byref(m), 0):
break
if m.StateFlags & 0x1:
mon = m
break
j += 1
cur = ''
rect = None
dm = DEVMODEW()
dm.dmSize = ctypes.sizeof(dm)
if user32.EnumDisplaySettingsW(dd.DeviceName, ENUM_CURRENT_SETTINGS,
ctypes.byref(dm)):
cur = f'{dm.dmPelsWidth}x{dm.dmPelsHeight}@{dm.dmDisplayFrequency}'
rect = (dm.dmPositionX, dm.dmPositionY, dm.dmPelsWidth, dm.dmPelsHeight)
out.append({
'device': dd.DeviceName, # \\.\DISPLAY1
'adapter': dd.DeviceString, # 显卡名
'mon_name': mon.DeviceString if mon else '',
'edid': read_edid_from_registry(mon.DeviceID) if mon else None,
'cur_mode': cur,
'rect': rect, # (x, y, w, h) 物理像素
})
i += 1
return out
def enum_modes(device):
"""枚举 device(\\\\.\\DISPLAYn) 可用模式 —— 显卡按该屏 EDID 提供,
返回 (['WxH@Hz', ...] 按像素数降序, {'WxH@Hz': DEVMODEW})"""
seen = {}
i = 0
while True:
dm = DEVMODEW()
dm.dmSize = ctypes.sizeof(dm)
if not user32.EnumDisplaySettingsW(device, i, ctypes.byref(dm)):
break
i += 1
if dm.dmBitsPerPel != 32:
continue
key = f'{dm.dmPelsWidth}x{dm.dmPelsHeight}@{dm.dmDisplayFrequency}'
seen.setdefault(key, dm)
keys = sorted(seen, key=lambda k: tuple(int(v) for v in k.replace('@', 'x').split('x')),
reverse=True)
return keys, seen
def apply_mode(device, dm):
"""把 device 切到 dm 模式(临时切换, 不写注册表, 重启还原), 返回 (成功?, 返回码)"""
dm.dmFields = DM_BITSPERPEL | DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY
r = user32.ChangeDisplaySettingsExW(device, ctypes.byref(dm), None, 0, None)
return r == 0, r # DISP_CHANGE_SUCCESSFUL == 0
def ensure_display_extend():
"""仅当转接板显示器"已接入但未进入桌面"时, 才强制扩展拓扑。
场景: 板卡热插拔后, 若 Windows 停留在"仅电脑屏幕"拓扑(会记住上次
Win+P 的选择), 板卡的 EDID 虽已安装、GPU 也在按首选时序输出, 但桌面
并未扩展过去, get_displays() 就枚举不到这块"显示器" —— 此时才需要
强制扩展把它拉回桌面。
用户主动选择的拓扑(家里的"仅第二屏幕"、手动关闭的显示器等)一律不动:
枚举"接入但不在桌面"的显示器, 只有其 EDID 厂商是转接板签名 "XLS"
(字节 0x61 0x93, 上游 lt8619c_edid.c) 时才扩展; 笔记本内屏等其它
厂商视为用户有意关闭, 不干预。
返回 SetDisplayConfig 的返回码(0=成功, -1=未触发, 其它=异常); 静默容错。
"""
try:
board_stuck = False
i = 0
while True:
dd = DISPLAY_DEVICEW()
dd.cb = ctypes.sizeof(dd)
if not user32.EnumDisplayDevicesW(None, i, ctypes.byref(dd), 0):
break
i += 1
j = 0
while True:
m = DISPLAY_DEVICEW()
m.cb = ctypes.sizeof(m)
if not user32.EnumDisplayDevicesW(dd.DeviceName, j, ctypes.byref(m), 0):
break
j += 1
if m.StateFlags & 0x1: # ATTACHED_TO_DESKTOP: 已在桌面
continue
raw = read_edid_from_registry(m.DeviceID)
if len(raw or ()) >= 10 and raw[8:10] == b'\x61\x93':
board_stuck = True # 转接板没进桌面 -> 需要拉回
if not board_stuck:
return -1
SDC_TOPOLOGY_EXTEND = 0x00000004
SDC_APPLY = 0x00000080
return ctypes.windll.user32.SetDisplayConfig(
0, None, 0, None, SDC_TOPOLOGY_EXTEND | SDC_APPLY)
except Exception:
return -1
+370
View File
@@ -0,0 +1,370 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DLPC3421 光机 RS485 控制链路(板上 GD32E230 作 RS485→I2C 网关)
================================================================
协议以 ../LT8619C_DLPC3421_HDMI/protocols.md 为准(上游固件仓, 含逐条命令的
响应语义/错误码/就绪门禁与实测帧示例):
物理层 RS485 半双工 115200 8N1(PC 经 USB-RS485 转换器接板, DE 由 MCU 硬件换向)
命令帧 D5 01 LEN <ASCII命令> CRC 01=板类型, LEN=命令字节数
响应帧 B5 TYPE LEN <payload> CRC payload 为 ASCII(ok / err:OL / okTL:246 ...),
例外: M303/M304/M305 载荷为大端二进制
(SQ52205 电源实测, 见 POWER_COMMANDS)
CRC 帧内第 2 字节(板类型)起累加到最后一个 payload 字节, 取低 8 位
帧长 最小 6 字节, 最大 32 字节
就绪门禁: DLPC3421 启动完成前 M730/M731/M732 被拒(回 0xFF), 属预期。
常用命令见 PRESETS(全表见 protocols.md); M7S0 是"开启"TEC 自动控温, 方向别记反。
仅标准库 + 可选 pyserial(缺失只影响串口功能, 同 hdmi_gui.py 的 Pillow 模式)。
"""
import collections
import queue
import re
import threading
import time
try:
import serial
import serial.tools.list_ports
HAS_SERIAL = True
except ImportError:
HAS_SERIAL = False
HEADER_TX = 0xD5 # 命令帧头(Host -> Device)
HEADER_RX = 0xB5 # 响应帧头(Device -> Host)
BOARD_TYPE = 0x01 # 板类型字节
FRAME_MIN, FRAME_MAX = 6, 32 # 整帧长度上下限(含帧头/CRC)
DEFAULT_BAUD = 115200
# --- 响应 TYPE 码语义(protocols.md "响应 TYPE 码"表) ---
TYPE_NAMES = {
0xF0: '成功',
0xF1: 'CRC 校验错',
0xF2: '包头错',
0xF3: '板类型错',
0xF4: '格式/缺参数',
0xFD: '参数越界/读回复核不一致',
0xFE: '未知命令',
0xFF: '通用错误(含 DLPC 就绪门禁拒绝)',
0xFA: '开光机通信失败',
0xFB: '关光机通信失败',
0xFC: '读回复核通信失败',
}
# --- payload 中 err:<N> 的 DLPC 侧错误码 ---
DLPC_ERR_NAMES = {
0: 'DLPC 成功',
1: 'DLPC 通用错误',
2: 'DLPC 通信超时',
3: 'I2C NACK(器件无应答)',
4: 'DLPC 参数无效',
5: 'DLPC 忙',
}
# GUI 预设命令表: (显示名, 命令, 说明), 顺序即下拉顺序
PRESETS = (
('开光机', 'M730S0', '开启 RGB 投光(需 DLPC 就绪)'),
('关光机', 'M730S1', '关闭投光并停定时器'),
('设光强', 'M731S100', 'RGB 三路同值, 范围 54-800, 写后读回复核'),
('查电流', 'M732', 'okR<n>G<n>B<n>, 三路电流原始值 0-65535'),
('查DMD温度', 'M201', 'okTL:246 = 24.6°C(有符号, 0.1°C)'),
('查LAMP温度', 'M202', '同 M201'),
('查投光结束', 'M737', '定时投光中不立即回包, 到期自动关灯后补发 ok'),
('查版本', 'M999', 'okv20.0.0.1 形式'),
('握手', 'M1', '协议握手, 回 ok'),
('TEC自动控温开', 'M7S0', 'S0=开启 PID 自动控温(方向别记反)'),
('TEC自动控温关', 'M7S1', 'S1=关闭'),
('凝露点33.0°C', 'M8S330', 'TEC 凝露点, 0.1°C 单位, 范围 200-350'),
('软件重启', 'M888', '回 ok 后 100ms 复位'),
)
Frame = collections.namedtuple('Frame', 'type_code payload raw')
# --- M303/M304/M305(SQ52205 电源实测值查询): 成功载荷为原始二进制(非 ASCII) ---
# M303 母线电压: u16 大端, 单位 mV; 12V 轨正常窗口 11400~12600(标称 ±5%)
# M304 负载电流: s16 大端补码, 单位 mA; 正=正向, 负=反向(固件原样上总线,
# 上位机必须按有符号解析)。例: 载荷 FF 06 = -250 mA
# M305 负载功率: u32 大端(LEN=4, 勿复用双字节解析), 单位 mW, 恒非负;
# 量程约 0~295000 mW; 能量方向看 M304 符号
# 错误时 TYPE=0xFF, 载荷为错误信息字节串(走通用译码路径)
POWER_COMMANDS = ('M303', 'M304', 'M305')
def payload_bytes(fr):
"""响应帧的原始载荷字节(Frame.payload 是 ASCII 解码视图, 二进制载荷请用本函数)"""
return fr.raw[3:-1]
def payload_u16_be(raw):
return (raw[0] << 8) | raw[1]
def payload_s16_be(raw):
v = payload_u16_be(raw)
return v - 0x10000 if v & 0x8000 else v
def payload_u32_be(raw):
return (raw[0] << 24) | (raw[1] << 16) | (raw[2] << 8) | raw[3]
def format_power(cmd, fr):
"""电源实测命令成功应答 -> 人读文本; 非电源命令/失败/载荷异常返回 None"""
if cmd not in POWER_COMMANDS:
return None
raw = payload_bytes(fr)
if cmd == 'M305':
if fr.type_code != 0xF0 or len(raw) != 4:
return None
mw = payload_u32_be(raw)
return f'{mw} mW ({mw / 1000:.2f} W)'
if fr.type_code != 0xF0 or len(raw) != 2:
return None
if cmd == 'M303':
mv = payload_u16_be(raw)
return f'{mv} mV ({mv / 1000:.2f} V)'
ma = payload_s16_be(raw)
return f'{ma:+d} mA'
def sum_crc(data):
"""帧内第 2 字节(板类型)起累加到末 payload 字节, 取低 8 位(不含包头与 CRC)"""
return sum(data) & 0xFF
def build_frame(cmd):
"""ASCII 命令字符串 -> 命令帧 bytes; 空命令/超长/非 ASCII 抛 ValueError"""
payload = cmd.strip().encode('ascii')
if not payload:
raise ValueError('空命令')
if len(payload) > FRAME_MAX - 4:
raise ValueError(f'命令过长(>{FRAME_MAX - 4} 字节, 帧上限 {FRAME_MAX})')
frame = bytes((HEADER_TX, BOARD_TYPE, len(payload))) + payload
return frame + bytes((sum_crc(frame[1:]),))
class FrameParser:
"""增量字节流 -> 完整响应帧: 帧头+LEN 定界, CRC 校验, 失配丢当前帧头重新同步。
payload 理论上可能含 0xB5 造成同步偏移(伪帧头 CRC 必错会被跳过),
下一帧自动恢复, 对调试工具可接受。
"""
def __init__(self):
self._buf = bytearray()
def feed(self, data):
"""喂入新收到的字节, 返回本次解析出的完整 Frame 列表"""
self._buf += data
frames = []
while True:
i = self._buf.find(HEADER_RX)
if i < 0:
self._buf.clear()
return frames
if i: # 丢弃帧头前的噪声
del self._buf[:i]
if len(self._buf) < 3: # 帧头/TYPE/LEN 尚不完整
return frames
length = self._buf[2]
if not FRAME_MIN - 4 <= length <= FRAME_MAX - 4:
del self._buf[:1] # LEN 非法 -> 伪帧头, 前进 1 字节重同步
continue
total = 4 + length
if len(self._buf) < total: # 整帧未收齐
return frames
raw = bytes(self._buf[:total])
if raw[-1] == sum_crc(raw[1:-1]):
frames.append(Frame(self._buf[1],
raw[3:-1].decode('ascii', 'replace').strip(), raw))
del self._buf[:total]
else: # CRC 失配 -> 丢帧头重同步
del self._buf[:1]
def _clean(s):
"""控制字符 -> '·': 二进制载荷按 ASCII 解出的 NUL 等会污染日志行,
且 Windows 剪贴板遇 NUL 直接截断复制内容"""
return ''.join(c if ord(c) >= 0x20 and ord(c) != 0x7F else '·' for c in s)
def describe(fr):
"""响应帧 -> 人读文本: TYPE 译码 + payload, err:N 追加 DLPC 错误释义。
二进制载荷(M303/M304/M305 电源实测)不以 ASCII 展示 —— 改显 hex,
避免乱码与 NUL 截断剪贴板(值由 GUI 读数框/CLI format_power 译码)。"""
pl = fr.payload
if any(ord(c) < 0x20 or ord(c) == 0x7F for c in pl):
pl = f'[二进制载荷 {fr.raw[3:-1].hex(" ").upper()}]'
else:
pl = _clean(pl)
if fr.type_code == 0xF0:
text = pl or 'ok'
else:
text = TYPE_NAMES.get(fr.type_code, f'TYPE=0x{fr.type_code:02X}')
if pl:
text += f' {pl}'
m = re.search(r'err:(\d+)', pl)
if m:
text += f'({DLPC_ERR_NAMES.get(int(m.group(1)), "未知错误码")})'
return text
class DLPC485:
"""串口工作线程: 端口/波特率热切换 + 命令队列 + 回调(收发全在后台线程)。
on_event(str) 收发日志行(已译码, 附原始 hex)
on_status(str) 连接状态变化(已连接/断开/失败/设备拔出)
回调在后台线程触发, GUI 侧需自行投递回主线程(见 hdmi_gui 的轮询队列)。
"""
def __init__(self, on_event, on_status, on_frame=None):
self.on_event, self.on_status, self.on_frame = on_event, on_status, on_frame
self.q = queue.Queue()
self._target = None # 想态 (port, baud, bytesize, parity, stopbits)
self._open_key = None # 当前已打开串口的参数元组
self.ser = None
self.parser = FrameParser()
self._lock = threading.Lock()
threading.Thread(target=self._run, daemon=True).start()
@staticmethod
def list_ports_info():
"""枚举串口, 返回 [(设备名, 显示标签), ...]。
标签 = "COMx 设备描述"(描述里的 "(COMx)" 尾巴去掉, COM 号已在最前),
CH344 这类多路适配器的每一路描述不同(如 serial-A/B/C/D), 依此区分;
多口描述完全相同(同型号多口)时追加物理位置/序列号消歧。
"""
if not HAS_SERIAL:
return []
items = []
for p in serial.tools.list_ports.comports():
desc = re.sub(r'\s*\((COM\d+)\)\s*$', '', (p.description or '').strip(),
flags=re.I)
if desc.upper() == p.device.upper():
desc = ''
label = f'{p.device} {desc}'.rstrip()
tail = p.location or p.serial_number or ''
items.append((p.device, label, tail))
counts = collections.Counter(label for _d, label, _t in items)
out = []
for dev, label, tail in items:
if counts[label] > 1 and tail: # 同标签多口: 附物理位置消歧
label = f'{label} [{tail}]'
out.append((dev, label))
return out
def open(self, port, baud=DEFAULT_BAUD, bytesize=8, parity='N', stopbits=1):
if not HAS_SERIAL:
self.on_status('未安装 pyserial: py -m pip install pyserial')
return
with self._lock:
self._target = (port, int(baud), int(bytesize),
str(parity).upper(), int(stopbits))
def close(self):
with self._lock:
self._target = None
def is_connected(self):
with self._lock:
return self.ser is not None
def send(self, cmd):
"""发送一条 ASCII 命令(自动组帧); 未连接/命令非法走 on_status 提示"""
with self._lock:
ser = self.ser
if ser is None:
self.on_status('未连接串口, 命令被丢弃')
return
try:
frame = build_frame(cmd)
except ValueError as e:
self.on_status(f'命令非法: {e}')
return
self.q.put((cmd, frame))
def _run(self):
# 串口参数映射(pyserial 常量): 数据位/校验/停止位
bytesizes = {8: serial.EIGHTBITS, 7: serial.SEVENBITS,
6: serial.SIXBITS, 5: serial.FIVEBITS}
parities = {'N': serial.PARITY_NONE, 'E': serial.PARITY_EVEN,
'O': serial.PARITY_ODD}
stopbits = {1: serial.STOPBITS_ONE, 2: serial.STOPBITS_TWO}
while True:
with self._lock:
target = self._target
cur = self._open_key
if target != cur:
if self.ser is not None:
try:
self.ser.close()
except Exception:
pass
self.ser = None
self._open_key = None
self.on_status('已断开')
if target:
port, baud, dbsize, parity, sbits = target
try:
self.ser = serial.Serial(
port, baud, bytesize=bytesizes.get(dbsize, serial.EIGHTBITS),
parity=parities.get(parity, serial.PARITY_NONE),
stopbits=stopbits.get(sbits, serial.STOPBITS_ONE),
timeout=0.15,
write_timeout=1.0) # 写超时: 转换器停滞时 write 会
# 永久阻塞并卡死线程, 必须设上限
self.parser = FrameParser()
self._open_key = target
self.on_status(f'已连接 {port} @ {baud} '
f'{dbsize}{parity}{sbits}')
except Exception as e:
self.ser = None
with self._lock:
self._target = None
blob = str(e) + repr(e)
if 'PermissionError' in blob or '拒绝访问' in blob \
or 'Access is denied' in blob:
hint = '串口被占用(串口助手/另一实例?)'
elif 'FileNotFoundError' in blob or '找不到指定的' in blob \
or 'No such file' in blob:
hint = '串口不存在,请刷新后重选'
else:
hint = '无法打开,详情见日志'
# 状态标签只放短提示(长异常串会撑爆参数行把窗口拉长),
# 完整异常走 on_event 进收发日志
self.on_status(f'连接失败: {hint}')
self.on_event(f'连接失败: {e!r}')
if self.ser is not None:
try:
item = None
try:
item = self.q.get_nowait()
except queue.Empty:
pass
if item is not None:
cmd, frame = item
self.ser.write(frame)
self.on_event(f'→ {cmd} [{frame.hex(" ").upper()}]')
data = self.ser.read(256)
if data:
for fr in self.parser.feed(data):
if self.on_frame is not None:
self.on_frame(fr) # 结构化帧(二进制载荷场景用)
self.on_event(f'← {describe(fr)} '
f'[{fr.raw.hex(" ").upper()}]')
except OSError:
try:
self.ser.close()
except Exception:
pass
self.ser = None
self._open_key = None # 必须同步清除, 否则同参数重连被当作已打开而跳过
with self._lock:
self._target = None
self.on_status('串口丢失(设备拔出?), 请重新连接')
time.sleep(0.02)
+548
View File
@@ -0,0 +1,548 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
EDID 1.4 / CEA-861 解析(纯标准库)
================================================================
parse_edid() 原始 EDID 字节 -> 结构化 dict(DTD/已建立/标准/CEA-VIC/数据块)
edid_info_lines() EDID 信息卡片用的 (项目, 值) 行
edid_field_rows()/edid_field_text() 逐字节字段解析
hex_dump() 经典 hex + ASCII 排版
"""
import math
import struct
# ============================================================
# EDID 1.4 / CEA-861 解析
# ============================================================
# CTA-861 VIC 1..64 常用映射(65+ 及扩展 VIC 不展开)
VIC_TABLE = [
'640x480@60', '720x480@60 4:3', '720x480@60 16:9', '1280x720@60', '1920x1080i@60',
'720x480i@60 4:3', '720x480i@60 16:9', '720x240p@60 4:3', '720x240p@60 16:9',
'1440x480i@60 4:3', '1440x480i@60 16:9', '1440x240p@60 4:3', '1440x240p@60 16:9',
'1440x480p@60 4:3', '1440x480p@60 16:9', '1920x1080p@60', '720x576p@50 4:3', '720x576p@50 16:9',
'1280x720p@50', '1920x1080i@50', '720x576i@50 4:3', '720x576i@50 16:9',
'720x288p@50 4:3', '720x288p@50 16:9',
'1440x576i@50 4:3', '1440x576i@50 16:9', '1440x288p@50 4:3', '1440x288p@50 16:9',
'1440x576p@50 4:3', '1440x576p@50 16:9', '1920x1080p@50', '1920x1080p@24',
'1920x1080p@25', '1920x1080p@30',
'2880x480p@60 4:3', '2880x480p@60 16:9', '2880x576p@50 4:3', '2880x576p@50 16:9',
'1920x1080i@50(1250)', '1920x1080i@100', '1280x720p@100',
'720x576p@100 4:3', '720x576p@100 16:9', '720x576i@100 4:3', '720x576i@100 16:9',
'1920x1080i@120', '1280x720p@120',
'720x480p@120 4:3', '720x480p@120 16:9', '720x480i@120 4:3', '720x480i@120 16:9',
'720x576p@200 4:3', '720x576p@200 16:9', '720x576i@200 4:3', '720x576i@200 16:9',
'720x480p@240 4:3', '720x480p@240 16:9', '720x480i@240 4:3', '720x480i@240 16:9',
'1280x720p@24', '1280x720p@25', '1280x720p@30', '1920x1080p@120', '1920x1080p@240',
# VIC 65-88 为 3D/特殊格式, 少见不展开
*[None] * 24,
'2560x1080p@50', '2560x1080p@60', '2560x1080p@100', '2560x1080p@120',
'3840x2160p@24', '3840x2160p@25', '3840x2160p@30',
'3840x2160p@50', '3840x2160p@60', '4096x2160p@24',
]
# 已建立时序位域表(bytes 35-37, 共 24 位, '-' 为保留位)
EST_TABLE = [
'720x400@70', '720x400@88', '640x480@60', '640x480@67', '640x480@72', '640x480@75',
'800x600@56', '800x600@60',
'800x600@72', '800x600@75', '832x624@75', '1024x768@87i', '1024x768@60', '1024x768@70',
'1024x768@75', '1280x1024@75',
'1152x870@75', '-', '-', '-', '-', '-', '-', '-',
]
STD_ASPECT = {0: (10 / 16, '16:10'), 1: (3 / 4, '4:3'), 2: (4 / 5, '5:4'), 3: (9 / 16, '16:9')}
DEPTHS = [0, 6, 8, 10, 12, 14, 16, 0]
INTERFACES = ['未定义', 'DVI', 'HDMI-A', 'HDMI-B', 'MDDI', 'DisplayPort', '保留', '保留']
SYNC_TYPES = ['模拟合成', '双极模拟合成', '数字合成', '数字分离']
# CEA-861 数据块 tag(CTA-861 标准): 1=音频 2=视频 3=厂商专用(VSDB)
# 4=扬声器分配 5=VESA DTC 6=视频格式 7=扩展 tag 集合
EXT_TAG_NAMES = {
0x00: '视频能力', 0x01: '厂商扩展', 0x02: 'VESA 显示设备', 0x04: 'HDMI 4K/8K 视频格式',
0x05: '色度学', 0x06: 'HDR 静态元数据', 0x07: 'HDR 动态元数据', 0x0C: 'YCbCr4:2:0 能力',
0x0E: 'YCbCr4:2:0 视频格式', 0x78: 'HDMI 论坛 SCDB',
}
def parse_dtd(raw, o):
"""解析 18 字节详细时序描述符, 返回 dict"""
t = {}
t['clk_khz'] = struct.unpack_from('<H', raw, o)[0] * 10 # 10kHz 单位
t['hact'] = raw[o + 2] | ((raw[o + 4] & 0xF0) << 4)
t['hblank'] = raw[o + 3] | ((raw[o + 4] & 0x0F) << 8)
t['vact'] = raw[o + 5] | ((raw[o + 7] & 0xF0) << 4)
t['vblank'] = raw[o + 6] | ((raw[o + 7] & 0x0F) << 8)
t['hfront'] = raw[o + 8] | ((raw[o + 11] & 0xC0) << 2)
t['hsync'] = raw[o + 9] | ((raw[o + 11] & 0x30) << 4)
t['vfront'] = ((raw[o + 10] & 0xF0) >> 4) | ((raw[o + 11] & 0x0C) << 2)
t['vsync'] = (raw[o + 10] & 0x0F) | ((raw[o + 11] & 0x03) << 4)
t['him_mm'] = raw[o + 12] | ((raw[o + 14] & 0xF0) << 4)
t['vim_mm'] = raw[o + 13] | ((raw[o + 14] & 0x0F) << 8)
fl = raw[o + 17]
t['interlaced'] = bool(fl & 0x80)
t['sync_type'] = SYNC_TYPES[(fl >> 2) & 3]
t['polarity'] = ('V+' if fl & 0x02 else 'V-') + ' ' + ('H+' if fl & 0x01 else 'H-')
t['htotal'] = t['hact'] + t['hblank']
t['vtotal'] = t['vact'] + t['vblank']
t['refresh'] = round(t['clk_khz'] * 1000.0 / (t['htotal'] * t['vtotal']), 1)
return t
def _ascii(raw, o, max_len):
out = []
for i in range(max_len):
c = raw[o + i]
if c in (0x0A, 0x00):
break
if 32 <= c < 127:
out.append(chr(c))
return ''.join(out)
def parse_edid(raw):
"""解析 EDID(128 或 256 字节), 返回汇总 dict"""
e = {'raw': raw, 'dtds': [], 'est': [], 'std': [], 'cea_video': [], 'cea_blocks': []}
n = len(raw)
e['cksum_base'] = (sum(raw[0:128]) & 0xFF) == 0
# 厂商 ID: 3 个 5bit 压缩大写字母
b0, b1 = raw[8], raw[9]
e['manufacturer'] = ''.join([
chr(((b0 & 0x7C) >> 2) + 64),
chr((((b0 & 0x03) << 3) | ((b1 & 0xE0) >> 5)) + 64),
chr((b1 & 0x1F) + 64)])
e['product'] = struct.unpack_from('<H', raw, 10)[0]
e['serial_num'] = struct.unpack_from('<I', raw, 12)[0]
e['week'] = raw[16]
e['year'] = 1990 + raw[17]
e['ver'] = f'{raw[18]}.{raw[19]}'
if raw[20] & 0x80:
bpc = DEPTHS[(raw[20] >> 4) & 0x7]
e['input'] = '数字 / ' + INTERFACES[raw[20] & 0x7] + \
(f' / {bpc}bpc' if bpc else ' / 色深未定义')
else:
e['input'] = '模拟 (VGA 输入)'
e['wcm'], e['hcm'] = raw[21], raw[22]
e['gamma'] = 0 if raw[23] == 0xFF else (raw[23] + 100) / 100.0
feats = []
if raw[24] & 0x80: feats.append('DPMS 待机')
if raw[24] & 0x40: feats.append('DPMS 挂起')
if raw[24] & 0x20: feats.append('DPMS 关闭')
if raw[24] & 0x04: feats.append('sRGB 特性')
if raw[24] & 0x02: feats.append('首选时序=原生')
e['features'] = ', '.join(feats)
# 已建立时序位域
for i in range(24):
bit = 7 - (i % 8)
if ((raw[35 + i // 8] >> bit) & 1) and EST_TABLE[i] != '-':
e['est'].append(EST_TABLE[i])
# 标准时序(8 组 2 字节, 0x0101 = 未用)
for i in range(38, 54, 2):
a, b = raw[i], raw[i + 1]
if (a == 1 and b == 1) or (a == 0 and b == 0):
continue
h = (a + 31) * 8
v = round(h * STD_ASPECT[(a >> 6) & 3][0])
e['std'].append(f'{h}x{v}@{b + 60}')
# 基础块 4 个 18 字节描述符区: 前两字节非 0 即 DTD, 否则是监视器描述符
e['name'] = e['serial_text'] = e['range_text'] = ''
for d in range(54, 126, 18):
if raw[d] == 0 and raw[d + 1] == 0:
tag = raw[d + 2]
if tag == 0xFC:
e['name'] = _ascii(raw, d + 5, 13)
elif tag == 0xFF:
e['serial_text'] = _ascii(raw, d + 5, 13)
elif tag == 0xFD:
e['range_text'] = (f"V {raw[d+5]}~{raw[d+6]}Hz, H {raw[d+7]}~{raw[d+8]}kHz, "
f"像素时钟上限 {raw[d+9] * 10}MHz")
else:
e['dtds'].append(parse_dtd(raw, d))
# CEA-861 扩展块
e['has_ext'] = raw[126] > 0 and n >= 256
e['has_cea'] = False
e['cea_rev'] = 0
e['cea_caps'] = ''
e['cea_phys'] = ''
e['cksum_ext'] = False
if e['has_ext']:
e['cksum_ext'] = (sum(raw[128:256]) & 0xFF) == 0
if raw[128] == 0x02:
e['has_cea'] = True
e['cea_rev'] = raw[129]
caps = []
if raw[131] & 0x80: caps.append('欠扫描')
if raw[131] & 0x40: caps.append('基本音频')
if raw[131] & 0x20: caps.append('YCbCr4:4:4')
if raw[131] & 0x10: caps.append('YCbCr4:2:2')
e['cea_caps'] = ', '.join(caps)
dtd_off = raw[130]
p = 132
while dtd_off > 4 and (p - 128) < dtd_off and p + 1 < 256:
tag = (raw[p] >> 5) & 7
length = raw[p] & 0x1F
if length == 0:
break
data = raw[p + 1:p + 1 + length]
if tag == 2: # 视频数据块: VIC 列表
for k in range(length):
vic = data[k] & 0x7F
native = ' (原生)' if data[k] & 0x80 else ''
m = (VIC_TABLE[vic - 1] if 1 <= vic <= len(VIC_TABLE) else None) \
or f'VIC#{vic}'
e['cea_video'].append(m + native)
elif tag == 1: # 音频数据块: N 个 3 字节 SAD
e['cea_blocks'].append(f'音频数据块 x{length // 3}')
elif tag == 4:
e['cea_blocks'].append('扬声器分配块')
elif tag == 5:
e['cea_blocks'].append('VESA DTC 块')
elif tag == 6:
e['cea_blocks'].append('视频格式块(VFDB)')
elif tag == 3:
# 厂商专用块: 前 3 字节 IEEE OUI(小端)
oui = bytes(data[0:3])
if oui == b'\x03\x0c\x00': # HDMI 1.x VSDB: OUI 后 4 字节物理地址
if length >= 7:
e['cea_phys'] = '.'.join(str(b) for b in data[3:7])
e['cea_blocks'].append('HDMI VSDB')
elif oui == b'\xd8\x5d\xc4': # HDMI 2.0 HF-VSDB
e['cea_blocks'].append('HDMI 2.0 HF-VSDB')
else:
e['cea_blocks'].append(f'厂商块 OUI={oui.hex(" ")}')
elif tag == 7:
ext = data[0] if length >= 1 else -1
name = EXT_TAG_NAMES.get(ext, f'0x{ext:02X}')
e['cea_blocks'].append(f'扩展块({name})')
else:
e['cea_blocks'].append(f'保留块 tag{tag}')
p += 1 + length
if dtd_off >= 4: # 扩展块 DTD 区
q = 128 + dtd_off
while q + 18 <= n:
if raw[q] == 0 and raw[q + 1] == 0:
break
e['dtds'].append(parse_dtd(raw, q))
q += 18
return e
# ============================================================
# 文本化
# ============================================================
def edid_info_lines(e):
"""[(项目, 值), ...] 摘要行"""
lines = []
inch = 0
if e['wcm'] > 0 and e['hcm'] > 0:
inch = round(math.hypot(e['wcm'], e['hcm']) / 2.54, 1)
lines.append(('厂商 / 产品码', f"{e['manufacturer']} / 0x{e['product']:04X}"))
lines.append(('显示器名称', e['name'] or '(未声明)'))
if e['serial_text']:
lines.append(('序列号(文本)', e['serial_text']))
lines.append(('生产周 / 年', f"第 {e['week']} 周 / {e['year']} 年"))
lines.append(('EDID 版本', e['ver']))
lines.append(('输入类型', e['input']))
if inch > 0:
lines.append(('屏幕尺寸', f"{e['wcm']}cm x {e['hcm']}cm (约 {inch} 英寸)"))
if e['gamma'] > 0:
lines.append(('Gamma', e['gamma']))
if e['features']:
lines.append(('特性', e['features']))
if e['range_text']:
lines.append(('范围限制', e['range_text']))
if e['has_ext']:
ck = 'OK' if e['cksum_ext'] else '错误!'
lines.append(('扩展块', f"CEA-861 rev.{e['cea_rev']} 校验和 {ck}"))
if e['cea_caps']:
lines.append(('CEA 能力', e['cea_caps']))
if e['cea_phys']:
lines.append(('HDMI 物理地址', e['cea_phys']))
else:
lines.append(('扩展块', '无'))
lines.append(('基础块校验和', 'OK (0x00)' if e['cksum_base'] else '错误!'))
# 每个 DTD 的完整时序参数(硬件调试重点)
for k, t in enumerate(e['dtds']):
tag = 'DTD1(首选)' if k == 0 else f'DTD{k + 1}'
lines.append((f'{tag} 模式', f"{t['hact']}x{t['vact']}@{t['refresh']} {t['clk_khz'] / 1000:.3f} MHz"))
lines.append((f'{tag} 总时序', f"{t['htotal']}x{t['vtotal']}"))
lines.append((f'{tag} 水平', f"有效 {t['hact']} | 前沿 {t['hfront']} | 同步 {t['hsync']} | "
f"后沿 {t['htotal'] - t['hact'] - t['hfront'] - t['hsync']}"))
lines.append((f'{tag} 垂直', f"有效 {t['vact']} | 前沿 {t['vfront']} | 同步 {t['vsync']} | "
f"后沿 {t['vtotal'] - t['vact'] - t['vfront'] - t['vsync']}"))
lines.append((f'{tag} 同步', f"{t['sync_type']} {t['polarity']} "
f"{'(隔行)' if t['interlaced'] else '(逐行)'}"))
if t['him_mm'] > 0:
lines.append((f'{tag} 图像尺寸', f"{t['him_mm']}x{t['vim_mm']} mm"))
return lines
def hex_dump(b):
out = []
for row in range((len(b) + 15) // 16):
off = row * 16
chunk = b[off:off + 16]
# 8+8 分组, 组间双空格: 同步头/厂商段一眼分开
hx = ' '.join(f'{v:02X}' for v in chunk[:8]) + ' ' + \
' '.join(f'{v:02X}' for v in chunk[8:])
asc = ''.join(chr(v) if 32 <= v < 127 else '.' for v in chunk)
out.append(f'{off:04X}: {hx:<50} {asc}')
return '\n'.join(out)
# ============================================================
# EDID 逐字节字段解析(调试视图): 基础块 + CTA-861 扩展块
# 每个字段一行: [偏移] 原始字节 | 含义 | 解码值 | 备注
# ============================================================
B_HEAD = '同步头 00 FF FF FF FF FF FF 00'
def _range_limits(d):
"""FD 频率范围描述符(18 字节常规布局) -> V/H 范围与像素时钟上限"""
try:
return (f"V {d[5]}~{d[6]}Hz, H {d[7]}~{d[8]}kHz, "
f"像素时钟上限 {d[9] * 10}MHz")
except (IndexError, ValueError):
return ''
def _bits(b, on, off=None):
return f'{b:02X}: ' + ('; '.join(on) if on else (off or '无置位'))
def edid_field_rows(raw):
"""把 EDID(128/256 字节)逐字段解成 [(偏移, 原始字节, 含义, 解码值, 备注), ...]"""
rows = []
n = len(raw)
def add(off, ln, meaning, val, note=''):
hx = raw[off:off + ln].hex(' ').upper()
if ln > 18: # 超长区域(如零填充)截断, 完整数据看 hex dump
hx = raw[off:off + 18].hex(' ').upper() + f' …(共 {ln} 字节)'
rows.append((f'0x{off:02X}-{off + ln - 1:02X}' if ln > 1 else f'0x{off:02X}',
hx, meaning, str(val), note))
add(0, 8, B_HEAD, 'OK' if raw[0:8] == bytes.fromhex('00FFFFFFFFFFFF00') else '不匹配!',
'不许改, 用于同步识别')
# 8-9 厂商 ID: 16 位大端, 3 组 5bit 压缩大写字母(块内唯一大端字段)
mfr = ''.join([chr(((raw[8] & 0x7C) >> 2) + 64),
chr((((raw[8] & 0x03) << 3) | ((raw[9] & 0xE0) >> 5)) + 64),
chr((raw[9] & 0x1F) + 64)])
add(8, 2, '厂商 ID(大端)', mfr,
f"3×5bit 压缩字母, 原始 0x{(raw[8] << 8) | raw[9]:04X}; 正式产品应使用注册的 PNP ID")
add(10, 2, '产品码(小端)', f"0x{struct.unpack_from('<H', raw, 10)[0]:04X} ({struct.unpack_from('<H', raw, 10)[0]})",
'厂商自定')
add(12, 4, '序列号(小端)', f"0x{struct.unpack_from('<I', raw, 12)[0]:08X}",
'厂商自定')
add(16, 1, '生产周', f"第 {raw[16]} 周", '0 = 不指定')
add(17, 1, '生产年', f"{1990 + raw[17]} 年", '字节值 + 1990')
add(18, 2, 'EDID 版本号', f"{raw[18]}.{raw[19]}", '常用 1.4 / 1.3(即字节 01 04 / 01 03)')
# 字节 0x14 输入类型
if raw[20] & 0x80:
itf, bpc = INTERFACES[raw[20] & 0x7], DEPTHS[(raw[20] >> 4) & 0x7]
val = f'数字 / {itf}' + (f' / {bpc}bpc' if bpc else ' / 色深未定义')
else:
val = '模拟输入'
add(20, 1, '输入类型', val,
'bit7=1 数字; 低 4 位: 1=HDMI-A 2=HDMI-B 4=DP 5=eDP')
add(21, 1, '最大画面宽(cm)', raw[21], '0 = 未知(横竖比由 DTD 图像尺寸给出)')
add(22, 1, '最大画面高(cm)', raw[22], '0 = 未知')
add(23, 1, 'Gamma', f"{(raw[23] + 100) / 100:.2f}" if raw[23] else '(未提供)',
'存储值 = gamma×100 − 100(如 0x78 → 2.20); 0xFF = 不提供')
add(24, 1, '特性位', _bits(raw[24], [
*(['DPMS 待机'] if raw[24] & 0x80 else []),
*(['DPMS 挂起'] if raw[24] & 0x40 else []),
*(['DPMS 关闭'] if raw[24] & 0x20 else []),
*([f"显示色彩 {['RGB 4:4:4', 'RGB+YCrCb 4:4:4', 'RGB+YCrCb 4:2:2', '未定义'][(raw[24] >> 3) & 3]}"]
if raw[24] & 0x80 else []),
*(['sRGB 默认'] if raw[24] & 0x04 else []),
*(['首选时序=原生分辨率'] if raw[24] & 0x02 else []),
*(['连续频率/GTF'] if raw[24] & 0x01 else []),
]), 'bit2 sRGB / bit1 首选时序有效 / bit0 连续频率')
add(25, 10, '色度坐标(CIE xy)', '默认 sRGB 模板' if raw[25:35] == bytes.fromhex(
'78EE95A3554D9EA6544C') else '(打包位段)', '红绿蓝白四点 xy 各 10bit 打包, 通常照抄模板')
est = []
est_bits = 0
for i in range(24):
bit = 7 - (i % 8)
if (raw[35 + i // 8] >> bit) & 1:
est_bits |= 1 << (23 - i)
if EST_TABLE[i] != '-':
est.append(EST_TABLE[i])
if est:
add(35, 3, 'Established Timing 位图', ', '.join(est),
'VESA 老 VGA 时序; 数字屏常写 FF FF 00=全支持')
else:
add(35, 3, 'Established Timing 位图',
'无有效声明' + ('(仅保留位置位)' if est_bits else ''),
'VESA 老 VGA 时序; 数字屏常写 FF FF 00=全支持')
stds = []
for i in range(38, 54, 2):
a, b = raw[i], raw[i + 1]
if (a == 1 and b == 1) or (a == 0 and b == 0):
continue
h = (a + 31) * 8
ar = STD_ASPECT[(a >> 6) & 3][1]
stds.append(f"{h}x{round(h * STD_ASPECT[(a >> 6) & 3][0])}@{b + 60} {ar}")
add(38, 16, '标准时序 ×8', '; '.join(stds) if stds else '8 槽全部未用(01 01)',
'2 字节/槽: 6bit 宽 + 2bit 比例 + 刷新率−60; 未用槽填 01 01')
# 0x36-0x7D: 4 × 18 字节描述符
for k, d in enumerate(range(54, 126, 18)):
grp = raw[d:d + 18]
if grp[0] or grp[1]:
t = parse_dtd(raw, d)
note = (f"{'(隔行)' if t['interlaced'] else '(逐行)'} "
f"{t['sync_type']} {'/'.join(t['polarity'].split())}; "
f"消隐 H{t['hblank']}/V{t['vblank']}, "
f"前/同/后 H{t['hfront']}/{t['hsync']}/{t['htotal'] - t['hact'] - t['hfront'] - t['hsync']} "
f"V{t['vfront']}/{t['vsync']}/{t['vtotal'] - t['vact'] - t['vfront'] - t['vsync']}")
add(d, 18, f'描述符{k + 1}: DTD(详细时序)',
f"{t['hact']}×{t['vact']}@{t['refresh']}Hz "
f"总 {t['htotal']}×{t['vtotal']} {t['clk_khz'] / 1000:.3f}MHz"
+ (' [首选时序]' if k == 0 else ''),
note + ('; 图像尺寸 %dx%d mm' % (t['him_mm'], t['vim_mm']) if t['him_mm'] else ''))
else:
tag = grp[3]
txt = _ascii(raw, d + 5, 13)
if tag == 0xFF:
add(d, 18, f'描述符{k + 1}: 序列号串(0xFF)', repr(txt), 'ASCII, 上限 13 字节')
elif tag == 0xFC:
add(d, 18, f'描述符{k + 1}: 显示器名(0xFC)', repr(txt), 'ASCII, 上限 13 字节')
elif tag == 0xFD:
add(d, 18, f'描述符{k + 1}: 范围限制(0xFD)', _range_limits(grp), '频率范围描述符')
elif tag == 0xFE:
add(d, 18, f'描述符{k + 1}: 无界文本(0xFE)', repr(txt), '')
elif tag == 0x10:
add(d, 18, f'描述符{k + 1}: 数据串(0x10)', grp[5:18].hex(' ').upper(), '二进制用途')
elif tag == 0 and not any(grp):
add(d, 18, f'描述符{k + 1}: 空', '(全 00)', '')
else:
add(d, 18, f'描述符{k + 1}: 未知类型 0x{tag:02X}', grp[5:18].hex(' ').upper(), '')
add(126, 1, '扩展块数量', raw[126], '必须与实际扩展块数一致')
cksum = (256 - sum(raw[0:127])) & 0xFF
add(127, 1, '基础块校验和', f"0x{raw[127]:02X} → " + ('正确' if (sum(raw[0:128]) & 0xFF) == 0 else '错误!'),
f"整块 128 字节和 ≡ 0 (mod 256); 期望 0x{cksum:02X} = 256 − 前 127 字节和")
# ---- CTA-861 扩展块 ----
n_ext = raw[126] if n > 126 else 0
for bi in range(n_ext):
base = 128 + bi * 128
if base + 128 > n:
add(base, 0, f'扩展块{bi + 1}', '(数据不完整)', '')
break
blk = raw[base:base + 128]
add(base, 1, f'扩展块{bi + 1} @0x{base:02X} Tag',
'CTA-861' if blk[0] == 0x02 else f'0x{blk[0]:02X}(非 CTA)',
'固定 0x02 = CTA 扩展; HDMI 必须有')
if blk[0] != 0x02:
add(base + 1, 127, '扩展块数据', blk[1:128].hex(' ').upper(), '未知扩展类型, 跳过')
continue
add(base + 1, 1, '版本', f"rev {blk[1]}", '常用 03')
add(base + 2, 1, 'DTD 起始偏移', f"0x{blk[2]:02X} (= 块内 {blk[2]} 字节处)", '数据块链结束、DTD 区开始的偏移')
add(base + 3, 1, '标志位', _bits(blk[3], [
*(['bit7 支持 YCbCr 4:2:0'] if blk[3] & 0x80 else []),
*(['bit6 支持基础音频'] if blk[3] & 0x40 else []),
*(['bit5 支持 YCbCr 4:2:2'] if blk[3] & 0x20 else []),
*(['bit4 支持 YCbCr 4:4:4'] if blk[3] & 0x10 else []),
*(['bit0 支持欠扫描'] if blk[3] & 0x01 else []),
]), 'bit6=基础音频, bit5=4:2:2, bit7=4:2:0, bit0=欠扫描')
# 数据块链: 每块 1 字节头(高 3 位 tag + 低 5 位长度) + payload
TAG_NAMES = {1: '音频', 2: '视频 VIC', 3: '厂商专用 VSDB', 4: '扬声器分配',
5: 'VESA DTC', 6: '视频格式 VFDB', 7: '扩展 tag'}
p, first = 4, True
while base + p < base + 128 and p < blk[2] and p + 1 < 128:
tag, length = (blk[p] >> 5) & 7, blk[p] & 0x1F
payload = blk[p + 1:p + 1 + length]
if first and blk[p] == 0:
add(base + p, 1, '数据块链', '(无数据块, 直接进 DTD 区)', '4 至 DTD 区前为数据块链')
break
first = False
if tag == 2:
vics = []
for v in payload:
vi = v & 0x7F
name = (VIC_TABLE[vi - 1] if 1 <= vi <= len(VIC_TABLE) else None) or f'VIC#{vi}'
vics.append(name + ('(原生)' if v & 0x80 else ''))
add(base + p, 1 + length, '数据块: 视频', ' '.join(vics) or '(空)', f"tag 2, {length} 字节")
elif tag == 1:
fmts = []
codes = ['保留', 'LPCM', 'AAC', 'AC-3', 'MPEG1', 'MP3', 'MPEG2', 'AAC LC',
'DTS', 'ATRAC', 'DSD', 'E-AC-3', 'DTS-HD', 'MLP', 'DST', 'WMAPro']
for si in range(length // 3):
sad = payload[si * 3:(si + 1) * 3]
ch = (sad[0] & 7) + 1
rates = '/'.join(str(r) for r, b in ((32, 1), (44, 2), (48, 4)) if sad[2] & b) or '?'
mults = ''.join(f'×{m}' for m, b in ((2, 8), (3, 16), (4, 32)) if sad[2] & b)
if (sad[0] >> 3) & 0xF == 1: # LPCM: 位宽在字节1低3位(位图)
bits = '/'.join(str(v) for v, b in ((16, 1), (20, 2), (24, 4)) if sad[1] & b) or '?'
fmts.append(f'LPCM {ch}ch {bits}bit {rates}kHz{mults}')
else: # 压缩格式: 字节1高4位=最高采样率档
fmts.append(f"{codes[(sad[0] >> 3) & 0xF]} {ch}ch {rates}kHz{mults}")
add(base + p, 1 + length, '数据块: 音频', '; '.join(fmts), f"tag 1, {length // 3} × 3 字节 SAD")
elif tag == 3:
oui = bytes(payload[0:3])
names = {b'\x03\x0c\x00': 'HDMI 1.x VSDB', b'\xd8\x5d\xc4': 'HDMI 2.0 HF-VSDB'}
val = names.get(oui, f'OUI={oui.hex(" ").upper()}')
if oui == b'\x03\x0c\x00' and length >= 7:
val += f", 物理地址 {'.'.join(str(b) for b in payload[3:7])}"
add(base + p, 1 + length, '数据块: 厂商专用', val,
f"tag 3, 头 3 字节 IEEE OUI(小端){'; HDMI 2.0 需 HF-VSDB' if oui == b'\x03\x0c\x00' else ''}")
else:
name = TAG_NAMES.get(tag, f'tag{tag}')
add(base + p, 1 + length, f'数据块: {name}',
payload.hex(' ').upper() if length else '(空)', f"{length} 字节 payload")
if length == 0:
break
p += 1 + length
# DTD 区
if blk[2] >= 4:
q, cnt = blk[2], 0
while q + 18 <= 128 and any(blk[q:q + 2]):
t = parse_dtd(blk, q)
add(base + q, 18, f'扩展块 DTD{cnt + 1}',
f"{t['hact']}×{t['vact']}@{t['refresh']}Hz 总 {t['htotal']}×{t['vtotal']} "
f"{t['clk_khz'] / 1000:.3f}MHz",
"前/同/后 H%d/%d/%d V%d/%d/%d %s" % (
t['hfront'], t['hsync'], t['htotal'] - t['hact'] - t['hfront'] - t['hsync'],
t['vfront'], t['vsync'], t['vtotal'] - t['vact'] - t['vfront'] - t['vsync'],
'(隔行)' if t['interlaced'] else '(逐行)'))
cnt += 1
q += 18
if not cnt:
add(base + blk[2], 0, '扩展块 DTD 区', '(空)', '未用空间填 0')
rem = 128 - max(blk[2], 4) - 18 * cnt if blk[2] >= 4 else 0
if blk[2] >= 4 and rem > 0 and not any(blk[128 - rem:128 - 1]):
add(base + 128 - rem, rem - 1, '未用空间', '0 填充', '')
add(base + 127, 1, f'扩展块{bi + 1} 校验和',
f"0x{blk[127]:02X} → " + ('正确' if (sum(blk) & 0xFF) == 0 else '错误!'),
f"期望 0x{(256 - sum(blk[0:127])) & 0xFF:02X} (同基础块规则)")
return rows
def edid_field_text(raw):
"""逐字段解析 -> 多行文本(命令行 / 文本框共用)"""
w = (7, 26, 20)
out = ['%-*s %-*s %-*s %s' % (w[0], '偏移', w[1], '含义', w[2], '解码值', '备注 / 原始字节')]
for off, hx, meaning, val, note in edid_field_rows(raw):
line = '%-*s %-*s %-*s %s' % (w[0], off, w[1], meaning, w[2], val, note)
out.append(line.rstrip())
if hx:
out.append('%-*s %-*s %s %s' % (w[0], '', w[1], '', '原始', hx))
return '\n'.join(out)
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
生成 640x360 黑白测试图, 供 HDMI 源端工具的自定义栏位使用
================================================================
输出到 ../assets/(仓库资产目录, 发布打包时随 zip 分发); 预置栏位配置
release/pattern_slots.json 指向前 3 张, 其余两张可用 "⚙" 换上。
图案清单(均为 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 math
import os
from PIL import Image, ImageDraw
W, H = 640, 360
SRC = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(os.path.dirname(SRC), 'assets')
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')
if __name__ == '__main__':
gen_star()
gen_sweep()
gen_checker_sweep()
gen_zoneplate()
gen_testcard()
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
生成 800x600 纯色测试图, 供 RGB 位交换(0x606E)验证使用
================================================================
配合 demo 板当前 EDID 首选时序 800x600@60, 全屏纯色输出。
用法: python gen_solid_colors.py → 输出到 ../assets/solid_*.png
在 DLPilot 中通过自定义栏位(⚙ 换图)加载, 发布包随附。
"""
import os
from PIL import Image
W, H = 800, 600 # 与 demo 板 EDID 首选时序一致, 全屏铺满
SRC = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(os.path.dirname(SRC), 'assets')
# (文件名后缀, 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()
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
HDMI EDID 查看器 + 测试图案投屏 + 光机控制 — 入口与命令行模式
================================================
配合 LT8619C(HDMI 转 RGB888)→ DLPC3421 转接板调试使用。
模块划分(重构自原单文件):
app_fluent.py PySide6-Fluent-Widgets 主界面(双栏仪表盘)
display_win.py Win32 显示器枚举/分辨率/拓扑
edid_parse.py EDID 1.4 / CEA-861 解析
patterns.py 测试图案全屏投屏窗口
config.py 运行目录与 pattern_slots.json 配置
dlpc_rs485.py 光机 RS485 协议与串口链路
依赖: CLI/EDID 纯标准库; GUI 需 customtkinter(py -m pip install customtkinter);
可选 pyserial(光机控制)与 Pillow(自定义图片 JPG/BMP/WebP)。
命令行模式(自动化测试用):
python hdmi_gui.py 启动 GUI
python hdmi_gui.py --dump 控制台输出 EDID 解算结果
python hdmi_gui.py --edid-fields 控制台输出 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 --port COM9 --dlpc-cmd M730S0 --dlpc-cmd M999
RS485 逐条发光机命令并打印译码响应
python hdmi_gui.py --seconds 5 GUI 5 秒自动关闭(冒烟测试)
"""
import argparse
import os
import sys
import time
import dlpc_rs485
import display_win
import edid_parse
import patterns
from config import (AREA_OPTIONS, BASE_DIR, DEFAULT_AREA, SLOT_COUNT, load_slots)
from display_win import ensure_display_extend, get_displays
from edid_parse import edid_field_text, edid_info_lines, hex_dump, parse_edid
from patterns import PATTERN_NAMES, show_pattern, show_pattern_image
def run_gui(auto_close=0):
"""启动 PySide6-Fluent-Widgets 主界面(实现在 app_fluent.py)"""
import app_fluent
app_fluent.run_gui(auto_close)
def run_dump(edid_fields=False):
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
if edid_fields:
print(edid_field_text(d['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 run_dlpc(port, baud, cmds):
"""RS485 直连模式: 逐条发送光机命令, 等响应打印译码结果(自动化脚本用)。
返回码: 任一命令非法/超时/响应非 0xF0 成功 -> 1, 全部成功 -> 0。
"""
if not dlpc_rs485.HAS_SERIAL:
print('错误: 未安装 pyserial, 请先执行 py -m pip install pyserial')
return 2
try:
ser = dlpc_rs485.serial.Serial(port, baud, timeout=0.1)
except Exception as e:
print(f'打开串口失败: {port} @ {baud}: {e}')
return 1
print(f'已连接 {port} @ {baud} (RS485 半双工, 单发单收)')
parser = dlpc_rs485.FrameParser()
rc = 0
for cmd in cmds:
try:
frame = dlpc_rs485.build_frame(cmd)
except ValueError as e:
print(f'→ {cmd} 命令非法: {e}')
rc = 1
continue
ser.write(frame)
print(f'→ {cmd} [{frame.hex(" ").upper()}]')
deadline = time.time() + 35.0 # M737 定时投光中延迟应答(定时上限 32.7s)
got = None
while got is None and time.time() < deadline:
data = ser.read(256)
if data:
got = next(iter(parser.feed(data)), None)
if got is None:
print('← (35s 超时无响应)')
rc = 1
else:
nice = dlpc_rs485.format_power(cmd, got) # 电源命令二进制载荷译码
print(f'← {nice or dlpc_rs485.describe(got)} [{got.raw.hex(" ").upper()}]')
if got.type_code != 0xF0:
rc = 1
ser.close()
return rc
def main():
ap = argparse.ArgumentParser(description='HDMI EDID 查看器 + 测试图案 + 光机控制')
ap.add_argument('--dump', action='store_true', help='控制台输出 EDID 解算结果, 不开 GUI')
ap.add_argument('--edid-fields', action='store_true',
help='控制台输出 EDID 逐字节字段解析表(配合 --dump 风格, 不开 GUI); exe 版不支持')
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/图案冒烟测试)')
ap.add_argument('--port', default=None, help='光机 RS485 串口(如 COM9), 配合 --dlpc-cmd')
ap.add_argument('--baud', type=int, default=dlpc_rs485.DEFAULT_BAUD,
help='光机串口波特率(默认 115200)')
ap.add_argument('--dlpc-cmd', action='append', default=None, dest='dlpc_cmds',
metavar='CMD',
help='RS485 发送光机命令(可多次), 如 --dlpc-cmd M730S0 --dlpc-cmd M999')
args = ap.parse_args()
# LT8619C 板卡热插拔后可能停留在"仅电脑屏幕"拓扑: 仅当检测到转接板
# (EDID 厂商 XLS)接入但未进桌面时才强制扩展, 其余拓扑不动(失败静默)
ensure_display_extend()
if args.dump or args.edid_fields:
if getattr(sys, 'frozen', False):
# --noconsole 打包的 exe 没有控制台可写, 引导用户用源码方式
import tkinter as tk
from tkinter import messagebox
r = tk.Tk()
r.withdraw()
messagebox.showinfo('不支持', 'exe 版本无控制台输出。\n请用源码方式运行:\n'
'python hdmi_gui.py --edid-fields')
sys.exit(1)
sys.exit(run_dump(edid_fields=args.edid_fields))
if args.dlpc_cmds:
if not args.port:
print('错误: --dlpc-cmd 需要配合 --port 指定串口(如 --port COM9)')
sys.exit(2)
sys.exit(run_dlpc(args.port, args.baud, args.dlpc_cmds))
if args.test_pattern:
displays = get_displays()
if not displays:
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()
+7
View File
@@ -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" %*
+487
View File
@@ -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 <n> -Mode <WxH[@Hz]> 切换分辨率, 如 -Mode 640x360 或 640x360@60
hdmi_source.bat [-Monitor <n>] -Pattern [-PatternName <名>] [-Seconds <n>]
全屏测试图案(Bar/GraySteps/Gradient/Checker/Grid/纯色)
可选: -Restore 退出图案窗口后恢复切换前的分辨率
-Seconds <n> 图案 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)
}
}
}
+120
View File
@@ -0,0 +1,120 @@
# Kingst VIS 压缩边沿流分析: 数字波形按占空比签名自动分段, 输出行结构/占空比/消隐明细
# 用法: python la_wave_analyzer.py <capture.csv>
# CSV 列: Time[s], 通道A, 通道B (时间分辨率 10ns; 默认抓 B 通道 D0/D1)
# 判读基线见 doc/architecture/rgb_bit_swap_test.md §1.1
import csv, statistics, time, sys
PATH = sys.argv[1] if len(sys.argv) > 1 else \
r"E:\Hulk-Coding\LT8619C_DLPC3421_HDMI\tools\wave\B-D0-D7.csv"
CLK_NS = 25.0 # 40MHz 像素时钟
t0 = time.time()
rows = []
with open(PATH, encoding='utf-8-sig', errors='replace') as f:
r = csv.reader(f)
next(r) # header
for row in r:
if len(row) < 3:
continue
rows.append((float(row[0]) * 1e9, int(row[1]), int(row[2]))) # ns
n = len(rows)
t_start, t_stop = rows[0][0], rows[-1][0] + 1000.0 # 末行状态延续按1us估
print(f"rows={n} span={t_stop - t_start / 1e9:.9f}s")
# ---- 按行区间累计高低时间, 50ms 窗口计算占空比 ----
W = 50_000_000.0 # ns
win = {'t0': t_start, 'hi0': 0.0, 'hi1': 0.0}
wins = []
for i in range(n - 1):
t, d0, d1 = rows[i]
dt = min(rows[i + 1][0], t_stop) - t
if dt <= 0:
continue
win['hi0'] += dt if d0 else 0.0
win['hi1'] += dt if d1 else 0.0
if rows[i + 1][0] - win['t0'] >= W:
span = rows[i + 1][0] - win['t0']
wins.append((win['t0'], win['hi0'] / span, win['hi1'] / span))
win = {'t0': rows[i + 1][0], 'hi0': 0.0, 'hi1': 0.0}
# ---- 窗口分类: 依 (d0duty, d1duty) 签名 ----
def classify(a, b):
hi = lambda x: x > 0.5
if hi(a) and hi(b):
return 'FF'
if hi(a) and not hi(b):
return '55'
if not hi(a) and hi(b):
return 'AA'
return '00'
segs = []
for t, a, b in wins:
c = classify(a, b)
if segs and segs[-1][0] == c:
segs[-1][1].append((t, a, b))
else:
segs.append((c, [(t, a, b)]))
print(f"\n{'段':<4}{'签名':<6}{'时长(s)':<10}{'D0占空':<10}{'D1占空':<10}")
for i, (c, ws) in enumerate(segs):
dur = (ws[-1][0] - ws[0][0]) / 1e9
a = statistics.mean(x[1] for x in ws)
b = statistics.mean(x[2] for x in ws)
print(f"{i:<6}{c:<8}{dur:<12.2f}{a:<12.1%}{b:<12.1%}")
# ---- 每段: 行周期 + 有效区宽度(长高电平段) ----
print(f"\n按段结构分析 (时钟数 @40MHz):")
for i, (c, ws) in enumerate(segs):
seg_t0 = ws[0][0] - W / 2
seg_t1 = ws[-1][0] + W / 2
ch = 0 if c in ('FF', '55') else 1 # 选有效区为高的通道
# 找长高电平段(>=15us)及其起点
starts, widths = [], []
for j in range(n - 1):
t, d0, d1 = rows[j]
if t < seg_t0 or t > seg_t1:
continue
lvl = d0 if ch == 0 else d1
if lvl and rows[j + 1][0] - t >= 15_000.0:
starts.append(t)
widths.append(rows[j + 1][0] - t)
if len(starts) < 3:
print(f" 段{i} [{c}]: 线段不足({len(starts)}), 跳过")
continue
periods = [b - a for a, b in zip(starts, starts[1:]) if 20_000 < b - a < 35_000]
mp = statistics.median(periods)
mw = statistics.median(widths)
print(f" 段{i} [{c}]: 行数~{len(starts)} 行周期={mp:.0f}ns={mp/CLK_NS:.1f}clk "
f"有效高={mw:.0f}ns={mw/CLK_NS:.1f}clk 消隐={(mp-mw)/CLK_NS:.1f}clk")
# ---- 每段抽一行: 消隐区的边沿明细 ----
print(f"\n消隐区边沿明细(每段第一行, ns 相对行起点, clk=时钟数):")
for i, (c, ws) in enumerate(segs):
seg_t0 = ws[0][0] - W / 2
ch = 0 if c in ('FF', '55') else 1
line_start = None
for j in range(n - 1):
t, d0, d1 = rows[j]
if t < seg_t0:
continue
lvl = d0 if ch == 0 else d1
if lvl and rows[j + 1][0] - t >= 15_000.0:
line_start = t
break
if line_start is None:
continue
print(f" 段{i} [{c}] 行起点+有效区之后:")
j0 = next(k for k in range(n - 1) if rows[k][0] >= line_start)
out = []
for k in range(j0, min(j0 + 30, n - 1)):
t, d0, d1 = rows[k]
dt = rows[k + 1][0] - t
rel = t - line_start
out.append(f" +{rel/1000:8.2f}us D0={d0} D1={d1} 持续{dt/CLK_NS:6.1f}clk")
if rel > 30_000:
break
print('\n'.join(out[:18]))
print(f"\n耗时 {time.time()-t0:.1f}s")
+74
View File
@@ -0,0 +1,74 @@
# Kingst VIS 3 通道边沿流分段: 按占空比签名分段, 输出各通道占空比
# 用法: python la_wave_analyzer3.py <capture.csv> (列: Time[s], ch8, ch9, ch10)
import csv, time, statistics
import sys
PATH = sys.argv[1] if len(sys.argv) else r"E:\Hulk-Coding\LT8619C_DLPC3421_HDMI\tools\wave\A1_RGB-SWAP.csv"
t0 = time.time()
rows = []
with open(PATH, encoding='utf-8-sig', errors='replace') as f:
r = csv.reader(f)
hdr = next(r)
print('header:', hdr)
for row in r:
if len(row) < 4:
continue
rows.append((float(row[0]) * 1e9, int(row[1]), int(row[2]), int(row[3])))
n = len(rows)
t_last = rows[-1][0] + 1000.0
print('rows=%d span=%.2fs' % (n, (t_last - rows[0][0]) / 1e9))
W = 50_000_000.0
wins = []
acc = {'t0': rows[0][0], 'hi': [0.0, 0.0, 0.0]}
for i in range(n - 1):
t, a, b, c = rows[i]
dt = min(rows[i + 1][0], t_last) - t
if dt <= 0:
continue
acc['hi'][0] += dt if a else 0.0
acc['hi'][1] += dt if b else 0.0
acc['hi'][2] += dt if c else 0.0
if rows[i + 1][0] - acc['t0'] >= W:
span = rows[i + 1][0] - acc['t0']
wins.append((acc['t0'], tuple(x / span for x in acc['hi'])))
acc = {'t0': rows[i + 1][0], 'hi': [0.0, 0.0, 0.0]}
def sig(d):
hi = [x > 0.5 for x in d]
return ('D7' if hi[0] else '--') + '/' + ('D15' if hi[1] else '--') + '/' + ('D23' if hi[2] else '--')
segs = []
for t, d in wins:
s = sig(d)
if segs and segs[-1][0] == s:
segs[-1][1].append((t, d))
else:
segs.append((s, [(t, d)]))
print('\n%-16s %10s %9s %9s %9s' % ('活动组签名', '时长(s)', 'D7占空', 'D15占空', 'D23占空'))
for s, ws in segs:
dur = ws[-1][0] - ws[0][0]
m = [statistics.mean(x[1][k] for x in ws) for k in range(3)]
print('%-16s %10.2f %8.1f%% %8.1f%% %8.1f%%' % (s, dur, m[0] * 100, m[1] * 100, m[2] * 100))
# 每段行结构抽查(第一段一次即可)
s0, ws0 = segs[0]
ch = 0
starts = []
seg_t0 = ws0[0][0] - W / 2
for j in range(n - 1):
t = rows[j][0]
if t < seg_t0:
continue
if rows[j][1] and rows[j + 1][0] - t >= 15000.0:
starts.append(t)
if len(starts) >= 5 or t > seg_t0 + 0.3e9:
break
if len(starts) >= 2:
p = statistics.median([b - a for a, b in zip(starts, starts[1:])])
print('\n行周期抽查: %.0fns (%.1f clk @40MHz)' % (p, p / 25.0))
print('耗时 %.1fs' % (time.time() - t0))
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DLPilot 图标栅格化: 黑底 512, 橙色圆角矩形, 第一行 DLP(框内黑字), 第二行 Pilot(白字)
================================================================
设计参数: 黑底 512, 橙色圆角矩形 x38 y64 w436 h186 rx34,
DLP 146px Arial Bold 黑字(框内居中), Pilot 146px Arial Bold 白字(基线 421)。
输出: logos/logo_dlpilot.png(512, 预览/复用)
logos/logo_dlpilot.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
# DLP: 居中于橙色框
d.text((cx, 64 * s + 186 * s / 2), 'DLP', font=f, fill=(0, 0, 0), anchor='mm')
# Pilot: 与原设计基线 421 对齐(无下伸字母, 中心 = 421 - cap/2 ~= 368)
d.text((cx, 368 * s), 'Pilot', font=f, fill=(255, 255, 255), anchor='mm')
return img
if __name__ == '__main__':
img = render(512)
png = os.path.join(BASE, 'logo_dlpilot.png')
ico = os.path.join(BASE, 'logo_dlpilot.ico')
img.save(png)
img.save(ico, sizes=[(16, 16), (24, 24), (32, 32), (48, 48),
(64, 64), (128, 128), (256, 256)])
print(f'写出 {png}')
print(f'写出 {ico}')
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
组装 DLPilot 发布包: exe + 附属资产 → 一个文件夹 + 一个 zip(解压即用)
================================================================
先把 assets/ 的图案复制进 release/(与 exe 平铺), 使 release/ 本身就是
可直接使用的完整文件夹; 再原样压成 zip —— 两者内容始终一致。
zip 解压后根目录为 DLPilot/, 与 exe 运行目录结构一致:
DLPilot.exe 主程序(先运行 build_exe.py 打包)
pattern_slots.json 预置自定义栏位(指向前 3 张 bw 图案)
bw_*.png 640x360 黑白测试图 ×5(自定义栏位用)
solid_*.png 800x600 纯色测试图 ×10(RGB 位交换测试用)
README.md 使用说明
用法:
python package_release.py 版本号自动取最近 git tag(DLPilot-v*)
python package_release.py 0.1.0 手动指定版本号
产物: release/DLPilot-v<版本>-win64.zip(zip 不入库, 上传到 Gitea Release)
"""
import os
import re
import shutil
import subprocess
import sys
import zipfile
SRC = os.path.dirname(os.path.abspath(__file__))
BASE = os.path.dirname(SRC)
RELEASE = os.path.join(BASE, 'release')
ASSETS = os.path.join(BASE, 'assets')
PATTERN_GLOBS = ('bw_*.png', 'solid_*.png')
def detect_version():
"""从最近的 DLPilot-v* tag 推断版本号, 无 tag 则报错提示手动指定"""
try:
out = subprocess.check_output(
['git', 'describe', '--tags', '--abbrev=0', '--match', 'DLPilot-v*'],
cwd=BASE, stderr=subprocess.DEVNULL)
return out.decode().strip().removeprefix('DLPilot-v')
except Exception:
sys.exit('未能从 git tag 推断版本号, 请手动指定: python package_release.py <版本号>')
def main():
version = (sys.argv[1] if len(sys.argv) > 1 else detect_version()).lstrip('v')
exe = os.path.join(RELEASE, 'DLPilot.exe')
slots = os.path.join(RELEASE, 'pattern_slots.json')
readme = os.path.join(BASE, 'README.md')
if not os.path.exists(exe):
sys.exit('缺少 release/DLPilot.exe, 先运行: python build_exe.py')
for must in (slots, readme):
if not os.path.exists(must):
sys.exit(f'缺少 {must}')
# 依 pattern_slots.json 校验栏位引用的图片都在 assets/ 里
slot_files = re.findall(r'"file":\s*"([^"]+)"', open(slots, encoding='utf-8').read())
patterns = sorted(p for g in PATTERN_GLOBS
for p in os.listdir(ASSETS)
if re.fullmatch(g.replace('*', '.*'), p))
for f in slot_files:
if f not in patterns:
sys.exit(f'pattern_slots.json 引用的 {f} 不在 assets/ 中')
# 图案复制进 release/, 与 exe 平铺 -> release/ 即完整可用的发布文件夹
for p in patterns:
shutil.copy2(os.path.join(ASSETS, p), os.path.join(RELEASE, p))
shutil.copy2(readme, os.path.join(RELEASE, 'README.md'))
zip_name = f'DLPilot-v{version}-win64.zip'
zip_path = os.path.join(RELEASE, zip_name)
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z:
for p in ['DLPilot.exe', 'pattern_slots.json', 'README.md'] + patterns:
z.write(os.path.join(RELEASE, p), f'DLPilot/{p}')
print(f'发布文件夹: {RELEASE} (exe+配置+说明+{len(patterns)} 张图案, 平铺即用)')
print(f'发布包: {zip_path} ({os.path.getsize(zip_path) / 1024 / 1024:.1f} MB)')
print('下一步上传: python publish_gitea.py', zip_name)
if __name__ == '__main__':
main()
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
测试图案全屏投屏窗口(tkinter): 纯色/棋盘格/网格线 + 自定义图片 1:1 居中
================================================================
投屏窗口为无边框黑底 Toplevel, ESC 或点击退出; 测试卡区域可选
640x360 / 800x600 / full 铺满(常量与图片加载配置在 config)。
"""
from config import DEFAULT_AREA, HAS_PIL, Image, ImageTk, PAT_W, PAT_H
# 当前打开的投屏窗口集合: 支持"新图案替换旧图案"与 close_all() 一键恢复桌面
_OPEN = set()
def close_all():
"""关闭所有打开的投屏窗口, 恢复桌面"""
for top in list(_OPEN):
try:
top.destroy()
except Exception:
pass
_OPEN.clear()
PATTERN_NAMES = {'Red': '纯红', 'Green': '纯绿', 'Blue': '纯蓝',
'Checker': '黑白棋盘格', 'Grid': '网格线'}
# 纯色测试值(位交换测试用, 与 assets/solid_*.png 对应):
# 每通道 55/AA 互为镜像对(检测对), FF 全高基准
SOLID_ROWS = (
('R', (('55', '#550000'), ('AA', '#AA0000'), ('FF', '#FF0000'))),
('G', (('55', '#005500'), ('AA', '#00AA00'), ('FF', '#00FF00'))),
('B', (('55', '#000055'), ('AA', '#0000AA'), ('FF', '#0000FF'))),
)
def load_image_raw(path):
"""按原始像素加载图片(不缩放不裁剪; PIL 仅用于支持 JPG/BMP/WebP 等格式)"""
import tkinter as tk
if HAS_PIL:
return ImageTk.PhotoImage(Image.open(path).convert('RGB'))
return tk.PhotoImage(file=path)
def _area_box(spec, w, h):
"""区域选项 -> (x0, y0, aw, ah): 'full' 铺满整个屏幕, 'WxH' 黑底居中"""
if spec == 'full':
return 0, 0, w, h
try:
aw, ah = (int(v) for v in spec.lower().split('x', 1))
except Exception:
aw, ah = PAT_W, PAT_H
return (w - aw) // 2, (h - ah) // 2, aw, ah
def _open_fullscreen(parent, rect, auto_close=0):
"""创建覆盖 rect(x,y,w,h) 的无边框全屏黑底窗口, 返回 (top, cv)
ESC 退出(需窗口持有键盘焦点); overrideredirect 窗口必须 focus_force
强取键盘焦点, 否则收不到按键(Windows 上该组合有效)。
窗口登记进 _OPEN: 打开新投屏前会关旧的(替换语义), close_all() 一键全关。
"""
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):
_OPEN.discard(top)
top.destroy()
def _on_destroy(e):
if e.widget is top: # 子控件销毁也冒泡到此, 只认窗口本身
_OPEN.discard(top)
top.bind('<Escape>', close)
top.bind('<Destroy>', _on_destroy)
_OPEN.add(top)
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 指定。
打开前先关闭已有投屏窗口(替换语义, 不会多层叠罗汉)。"""
close_all()
top, cv = _open_fullscreen(parent, rect, auto_close)
def draw(_evt=None):
cv.delete('all')
w, h = max(cv.winfo_width(), rect[2]), max(cv.winfo_height(), rect[3])
x0, y0, aw, ah = _area_box(area, w, h)
if kind.startswith('#'): # 纯色测试值: 直接填充指定颜色
cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill=kind, outline='')
elif kind == 'Red':
cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill='#FF0000', outline='')
elif kind == 'Green':
cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill='#00FF00', outline='')
elif kind == 'Blue':
cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill='#0000FF', outline='')
elif kind == 'Checker':
# 40px 黑白格, 格数随区域尺寸自适应(640x360 = 16x9 格)
for gy in range((ah + 39) // 40):
for gx in range((aw + 39) // 40):
if (gx + gy) % 2 == 0:
cv.create_rectangle(x0 + gx * 40, y0 + gy * 40,
x0 + (gx + 1) * 40, y0 + (gy + 1) * 40,
fill='#FFFFFF', outline='')
elif kind == 'Grid':
# 20px 白色网格线, 黑底, 无其它修饰
for k in range(aw // 20 + 1):
cv.create_line(x0 + k * 20, y0, x0 + k * 20, y0 + ah, fill='#FFFFFF')
for k in range(ah // 20 + 1):
cv.create_line(x0, y0 + k * 20, x0 + aw, y0 + k * 20, fill='#FFFFFF')
top.bind('<Configure>', draw)
top.after(50, draw)
top.wait_window(top)
def show_pattern_image(parent, rect, path, auto_close=0, area=DEFAULT_AREA):
"""全屏黑底, 图片 1:1 居中于 area 指定的测试卡区域:
小于区域 -> 真实像素居中, 四周黑边;
大于区域 -> 等效从中心截取(区域外用黑矩形遮挡)。
打开前先关闭已有投屏窗口(替换语义)。"""
close_all()
top, cv = _open_fullscreen(parent, rect, auto_close)
def draw(_evt=None):
cv.delete('all')
w, h = max(cv.winfo_width(), rect[2]), max(cv.winfo_height(), rect[3])
x0, y0, aw, ah = _area_box(area, w, h)
photo = load_image_raw(path)
top._photo = photo # 挂在窗口上防止被垃圾回收
iw, ih = photo.width(), photo.height()
cv.create_image(x0 + (aw - iw) // 2, y0 + (ah - ih) // 2,
image=photo, anchor='nw')
# 四块黑矩形盖住测试卡区域之外 -> 大图等效中心裁剪
cv.create_rectangle(-2, -2, w + 2, y0, fill='black', outline='')
cv.create_rectangle(-2, y0 + ah, w + 2, h + 2, fill='black', outline='')
cv.create_rectangle(-2, y0, x0, y0 + ah, fill='black', outline='')
cv.create_rectangle(x0 + aw, y0, w + 2, y0 + ah, fill='black', outline='')
top.bind('<Configure>', draw)
top.after(50, draw)
top.wait_window(top)
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
创建/更新 Gitea Release 并上传发布 zip(纯标准库, 无需安装依赖)
================================================================
用法:
python publish_gitea.py DLPilot-v0.1.0-win64.zip
python publish_gitea.py <zip> --notes-file release/notes-v0.1.0.md
python publish_gitea.py <zip> --tag DLPilot-v0.1.0 --replace
行为:
- tag 与版本默认取 zip 文件名(DLPilot-vX.Y.Z-win64.zip → DLPilot-vX.Y.Z)
- release 不存在则创建(tag 不存在时 Gitea 在默认分支头自动建 tag);
已存在则复用, 仅上传/更新资产
- release 上已有同名资产时: 默认跳过, --replace 删旧再传
- API token: --token 参数或环境变量 GITEA_TOKEN(需该仓库写权限)
"""
import argparse
import json
import os
import re
import ssl
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
DEFAULT_NOTES = """## DLPilot {version}
HDMI 源端调试工具(LT8619C → DLPC3421 转接板配套): EDID 查看/逐字节解析、
分辨率切换、纯色与测试图案全屏投屏。
### 安装
下载 `DLPilot-v{version}-win64.zip`, 解压后双击 `DLPilot.exe`(Windows 10/11 x64, 免安装)。
### 包内容
- `DLPilot.exe` — 主程序
- `pattern_slots.json` — 预置自定义栏位(指向 bw_*.png)
- `bw_*.png` ×5 / `solid_*.png` ×10 — 测试图案
- `README.md` — 完整使用说明
"""
def git_remote_info():
"""从 origin 远程地址解析 API base 与 owner/repo(ssh 端口不影响 https API)"""
url = subprocess.check_output(['git', 'remote', 'get-url', 'origin']).decode().strip()
m = re.match(r'(?:ssh://git@|git@)([\w.-]+)(?::\d+)?[:/]([\w.-]+)/([\w.-]+?)(?:\.git)?$', url)
if not m:
sys.exit(f'无法从 origin 解析 Gitea 地址: {url}')
host, owner, repo = m.groups()
return f'https://{host}', owner, repo
class Api:
def __init__(self, base, owner, repo, token, insecure):
self.api = f'{base}/api/v1/repos/{owner}/{repo}'
self.headers = {'Authorization': f'token {token}'}
self.ctx = ssl._create_unverified_context() if insecure else None
def request(self, method, path, data=None, headers=None, retries=4):
"""发请求; 实例 TLS 间歇性抽风(SSL UNEXPECTED_EOF), 连接级错误自动重试"""
last = None
for attempt in range(retries):
req = urllib.request.Request(self.api + path, data=data, method=method,
headers={**self.headers, **(headers or {})})
try:
with urllib.request.urlopen(req, context=self.ctx) as resp:
body = resp.read()
return resp.status, json.loads(body) if body else None
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b'{}')
except (urllib.error.URLError, ssl.SSLError, OSError) as e:
last = e
time.sleep(1.0 + attempt)
raise RuntimeError(f'Gitea 请求连续 {retries} 次失败: {last}')
def get_json(self, path):
status, data = self.request('GET', path)
return data if status == 200 else None
def upload_asset(self, release_id, filepath, replace):
name = os.path.basename(filepath)
status, rel = self.request('GET', f'/releases/{release_id}')
if status != 200:
sys.exit(f'读取 release {release_id} 失败: {status} {rel}')
# Gitea 版本差异: assets 可能是 {items:[...]} 或纯数组
assets = rel.get('assets') or []
if isinstance(assets, dict):
assets = assets.get('items', [])
for asset in assets:
if asset['name'] == name:
if not replace:
print(f'资产 {name} 已存在, 跳过(覆盖请加 --replace)')
return
self.request('DELETE', f"/releases/{release_id}/assets/{asset['id']}")
print(f'已删除旧资产 {name}')
boundary = uuid.uuid4().hex
payload = open(filepath, 'rb').read()
body = (f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'
f'Content-Type: application/octet-stream\r\n\r\n').encode() + payload + \
f'\r\n--{boundary}--\r\n'.encode()
status, data = self.request(
'POST', f'/releases/{release_id}/assets?name={name}', body,
{'Content-Type': f'multipart/form-data; boundary={boundary}'})
if status != 201:
sys.exit(f'上传资产失败: {status} {data}')
print(f'已上传资产: {name} ({len(payload) / 1024 / 1024:.1f} MB)')
def main():
ap = argparse.ArgumentParser()
ap.add_argument('zipfile_path', help='发布 zip(release/ 下)')
ap.add_argument('--tag', help='release 标签, 默认取 zip 文件名去掉 -win64.zip')
ap.add_argument('--notes-file', help='release 说明 markdown 文件, 默认用内置模板')
ap.add_argument('--token', default=os.environ.get('GITEA_TOKEN'), help='API token')
ap.add_argument('--replace', action='store_true', help='覆盖已存在的同名资产')
ap.add_argument('--insecure', action='store_true', help='跳过 TLS 证书校验(自签证书)')
args = ap.parse_args()
if not os.path.exists(args.zipfile_path):
sys.exit(f'找不到 {args.zipfile_path}')
if not args.token:
sys.exit('缺少 API token: 传 --token 或设环境变量 GITEA_TOKEN')
tag = args.tag or re.sub(r'-win64\.zip$', '', os.path.basename(args.zipfile_path))
version = tag.removeprefix('DLPilot-v')
notes = open(args.notes_file, encoding='utf-8').read() if args.notes_file \
else DEFAULT_NOTES.format(version=version)
base, owner, repo = git_remote_info()
api = Api(base, owner, repo, args.token, args.insecure)
print(f'目标: {base}/{owner}/{repo} tag={tag}')
rel = api.get_json(f'/releases/tags/{tag}')
if rel:
print(f'release 已存在(id={rel["id"]}), 复用')
else:
status, rel = api.request('POST', '/releases', json.dumps({
'tag_name': tag,
'name': f'DLPilot v{version}',
'body': notes,
}).encode(), {'Content-Type': 'application/json'})
if status != 201:
sys.exit(f'创建 release 失败: {status} {rel}')
print(f'release 已创建(id={rel["id"]})')
api.upload_asset(rel['id'], args.zipfile_path, args.replace)
print(f'完成: {rel.get("html_url", "") or f"{base}/{owner}/{repo}/releases/tag/{tag}"}')
if __name__ == '__main__':
main()
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""dlpc_rs485 协议自检(无硬件): 对照上游 protocols.md 的实测帧向量做回归
用法: python selftest_rs485.py (全部通过退出码 0)
"""
import sys
import dlpc_rs485 as d
fails = []
def check(name, got, want):
ok = got == want
print(('PASS' if ok else 'FAIL'), name, '->', got, '' if ok else f'(期望 {want})')
if not ok:
fails.append(name)
# 1) 帧构造: protocols.md 实测示例 M730S0T2000
f = d.build_frame('M730S0T2000')
check('build_frame(M730S0T2000)', f.hex(' ').upper(),
'D5 01 0B 4D 37 33 30 53 30 54 32 30 30 30 8C')
# 2) 增量解析: ok 应答跨两个半包喂入
p = d.FrameParser()
r1 = p.feed(bytes.fromhex('B5 F0'))
r2 = p.feed(bytes.fromhex('02 6F 6B CC'))
check('split-feed frames', len(r1) + len(r2), 1)
fr = (r1 + r2)[0]
check('type_code', fr.type_code, 0xF0)
check('payload', fr.payload, 'ok')
check('describe(ok)', d.describe(fr), 'ok')
# 3) 前导噪声 + 0xFD err:OL 译码(字符串标记, 不附 DLPC 释义)
body = bytes.fromhex('FD 06 65 72 72 3A 4F 4C') # "err:OL"
good = bytes([0xB5]) + body + bytes([d.sum_crc(body)])
frames = d.FrameParser().feed(b'\x01\x02' + good)
check('noise+err-frame count', len(frames), 1)
check('describe(err:OL)', d.describe(frames[0]), '参数越界/读回复核不一致 err:OL')
# 4) 0xFA + 数字错误码 err:3 追加 DLPC 释义
body = bytes.fromhex('FA 05') + b'err:3'
fa = bytes([0xB5]) + body + bytes([d.sum_crc(body)])
frames = d.FrameParser().feed(fa)
check('describe(err:3)', d.describe(frames[0]),
'开光机通信失败 err:3(I2C NACK(器件无应答))')
# 5) CRC 坏帧被丢弃后继续同步到下一帧
bad = bytearray(good)
bad[-1] ^= 0xFF
frames = d.FrameParser().feed(bytes(bad) + good)
check('bad-crc then good', len(frames), 1)
check('recovered type', frames[0].type_code, 0xFD)
# 6) 非法输入拒绝
for bad_cmd in ('', ' '):
try:
d.build_frame(bad_cmd)
fails.append('empty-cmd-not-rejected')
except ValueError:
pass
try:
d.build_frame('M' * 40)
fails.append('long-cmd-not-rejected')
except ValueError:
pass
print('空/超长命令拒绝: PASS')
# 7) 预设命令全部可组帧
for _name, cmd, _note in d.PRESETS:
d.build_frame(cmd)
print(f'预设 {len(d.PRESETS)} 条全部可组帧: PASS')
# 8) M303/M304(SQ52205 电源实测)回归向量: 上游真机抓取帧
check('build_frame(M303)', d.build_frame('M303').hex(' ').upper(),
'D5 01 04 4D 33 30 33 E8')
check('build_frame(M304)', d.build_frame('M304').hex(' ').upper(),
'D5 01 04 4D 33 30 34 E9')
frames = d.FrameParser().feed(bytes.fromhex('B5 F0 02 2E F4 14'))
check('M303 应答帧', len(frames), 1)
check('M303 电压(0x2EF4=12020mV)', d.format_power('M303', frames[0]),
'12020 mV (12.02 V)')
frames = d.FrameParser().feed(bytes.fromhex('B5 F0 02 00 34 26'))
check('M304 应答帧', len(frames), 1)
check('M304 电流(0x0034=+52mA)', d.format_power('M304', frames[0]), '+52 mA')
check('M304 负值(FF06=-250mA 补码)', d.payload_s16_be(bytes.fromhex('FF 06')), -250)
# 9) M305(SQ52205 实测功率)回归向量: u32 大端 mW(LEN=4, 算法推演帧)
check('build_frame(M305)', d.build_frame('M305').hex(' ').upper(),
'D5 01 04 4D 33 30 35 EA')
frames = d.FrameParser().feed(bytes.fromhex('B5 F0 04 00 00 8C A0 20'))
check('M305 应答帧', len(frames), 1)
check('M305 功率(0x8CA0=36000mW)', d.format_power('M305', frames[0]),
'36000 mW (36.00 W)')
check('M305 拒绝双字节载荷', d.format_power(
'M305', d.FrameParser().feed(bytes.fromhex('B5 F0 02 8C A0 1E'))[0]), None)
# 10) 二进制载荷的日志行安全: 无控制字符(NUL 会截断 Windows 剪贴板), 以 hex 展示
frames = d.FrameParser().feed(bytes.fromhex('B5 F0 02 00 34 26')) # M304 应答(含 NUL)
desc = d.describe(frames[0])
check('二进制载荷 hex 展示', desc, '[二进制载荷 00 34]')
check('日志行无控制字符', any(ord(c) < 0x20 for c in desc), False)
print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS')
sys.exit(1 if fails else 0)
+82
View File
@@ -0,0 +1,82 @@
# coding: utf-8
"""主题切换回归自检(无窗口, 离屏渲染)。
背景: fluent 控件直接 setStyleSheet 的样式会在 setTheme 时被库重刷的自带 qss 冲掉
(v0.4.0 RGB swatch 按钮切主题丢底色的根因)。swatch 改走 setCustomStyleSheet、
标题字号走 QFont 后,本测试保证:
1. Dark -> Light -> Dark -> Light 往返后 9 个 swatch 按钮样式表仍带底色;
2. Light 下实际渲染出的按钮中心像素 = swatch 色值(不只样式表字符串存在);
3. 标题 24px 加粗(QFont)在往返后不变。
运行: python selftest_theme.py (依赖 PySide6-Fluent-Widgets)
"""
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QApplication
from qfluentwidgets import PushButton, StrongBodyLabel, Theme, setTheme
import app_fluent
import patterns
def check_style_sheet(win, app):
swatches = [b for b in win.findChildren(PushButton) if b.text() in {"55", "AA", "FF"}]
assert len(swatches) == 9, f"expect 9 swatch buttons, got {len(swatches)}"
title = [l for l in win.findChildren(StrongBodyLabel) if l.text() == "DLPilot"]
assert len(title) == 1, "title label not found"
colored = sum(1 for b in swatches if "background-color: #" in b.styleSheet())
f = title[0].font()
print(f" swatch 样式表带底色: {colored}/9, 标题 24px 粗体: "
f"{f.pixelSize() == 24 and f.bold()}")
return colored == 9 and f.pixelSize() == 24 and f.bold()
def check_rendered_pixel(win, app):
"""Light 主题下逐个渲染 swatch,与目标色一致的像素须占明显多数(背景为主,
中心处是文字字形,不能只采单点)。"""
ok = True
for channel, items in patterns.SOLID_ROWS:
for label, color in items:
button = next(b for b in win.findChildren(PushButton)
if b.text() == label and f"background-color: {color}" in b.styleSheet())
image = button.grab().toImage()
target = QColor(color)
total, matched = image.width() * image.height(), 0
for y in range(image.height()):
for x in range(image.width()):
if image.pixelColor(x, y) == target:
matched += 1
ratio = matched / total
same = ratio >= 0.2
ok &= same
if not same:
print(f" 渲染不符: {channel}/{label} 期望 {color} 仅占 {ratio:.0%}")
print(f" Light 下渲染像素比对: {'一致' if ok else '存在偏差'}")
return ok
def main():
app = QApplication.instance() or QApplication(sys.argv)
setTheme(Theme.AUTO)
win = app_fluent.App(0)
passed = True
for step, theme in (("初始 Dark", Theme.DARK), ("切 Light", Theme.LIGHT),
("切回 Dark", Theme.DARK), ("再切 Light", Theme.LIGHT)):
setTheme(theme)
app.processEvents()
print(f"[{step}]")
passed &= check_style_sheet(win, app)
if theme == Theme.LIGHT:
passed &= check_rendered_pixel(win, app)
win.close()
print("ALL PASS" if passed else "FAILED")
return 0 if passed else 1
if __name__ == "__main__":
sys.exit(main())
+8
View File
@@ -0,0 +1,8 @@
# 本机虚拟环境(根 .gitignore 已覆盖 build/ dist/ *.spec __pycache__/)
.venv/
# 构建日志(测试结果由 unittest 实时输出, 不留档)
build-log*.txt
# 验证脚本生成的截图
validation/*.png
+53
View File
@@ -0,0 +1,53 @@
# EDID Viewer — 开发需求与进度
## 基本环境
- 主项目位置:`E:\Hulk-Coding\HW-Debug-Tools\EDID-viewer`;为兼容此前约定,已同步镜像到 `E:\Hulk-Coding\LT8619C\_Debug\_Tools\EDID-viewer`。
- Windows 10/11 x64;Python 3.14;PySide6 6.11.2(Qt 官方 Python 绑定);PySide6-Fluent-Widgets 1.11.3。
- 使用项目 `.venv`;依赖见 requirements.txt;GUI 入口为 `src/main.py`(版本号在 `src/main.py` 的 `APP_VERSION`)。
- 独立工具,不依赖、不修改 LT8619C 固件或 DLPilot 工程。
## 工程结构与流水线(对齐 DLPilot 三区约定)
- 三区目录:`src/` 人写代码(main / edid / windows_edid / theme / test_edid + 打包流水线);`assets/` 图标与生成脚本(可再生);`release/` 交付(`EDID-Viewer.exe` 入库 + README)。
- 打包单文件 exe:`python src/build_exe.py`(spec 由脚本生成到 `src/build/`,不入库;原 viewer.spec 的 ICU 排除逻辑已内置其中)。`--debug` 附带带控制台变体。
- 发布:`python src/package_release.py <版本>` 组装 `release/EDID-Viewer-v*-win64.zip`(zip 不入库,只上传 Gitea Release);上传用 `python src/publish_gitea.py <zip>`,token 走环境变量 `GITEA_TOKEN`。
- 解析测试:`cd src && python -m unittest`(9 项,改 edid.py 后必跑)。
## 任务目标
1. 枚举 Windows 活动显示设备并读取 EDID 原始块。
2. 查看厂家代码、产品编号、名称、序列号、生产日期、分辨率和详细时序。
3. 展示 CTA 扩展、HDMI、音频、HDR、色彩能力和校验结果。
4. 支持离线 BIN/HEX 导入、BIN 和 JSON 报告导出。
5. Light / Dark / 跟随系统主题,持久化用户选择并响应系统主题变化。
6. 图标参考 DLPilot 黑底、橙色标签、白字,使用 EDID / Viewer 独立标识。
- Fluent 主题包含 `dark1`(蓝色)和 `dark2`(青色),两者均为显式深色模式;选中态文字必须保持高对比度。
- Fluent Widgets 版本已采用顶部操作区、下方信息卡片和分组页签;后续布局调整需保持信息层级清晰。
## Fluent Widgets 版本
- 界面使用 PySide6-Fluent-Widgets 的 `CardWidget`、`ComboBox`、`PrimaryPushButton`、`PlainTextEdit` 和 `TreeWidget`;顶部操作区、下方信息卡片和分组页签。
- Fluent 主题只负责表现层;EDID 解析、Windows WMI 读取、导入导出和线程边界不改变。
- 主题切换必须三件套同步(与 DLPilot `app_fluent.py` 同一方案):`setTheme` 之外,QPalette + 应用级 QSS 统一 QMainWindow 背景、原生 QTabWidget/QStatusBar 表面色,DWM 属性 20/35/36 切换原生标题栏深浅。只调 `setTheme` 会导致 Light 模式下窗口背景、页签和标题栏仍是系统深色。
- 窗口截图验证可用 `validation\capture-window.ps1`(按进程窗口矩形截屏,注意全屏窗口会遮挡)。
## 实现与边界
- 只读 Windows WMI 显示驱动提供的 EDID;不写 EDID、不更改分辨率或显示拓扑。
- Windows/驱动可能缓存或覆盖 EDID;不能声称数据是实时 DDC 总线抓取。
- HDMI、DP、内屏均列出,连接类型来自 WmiMonitorConnectionParams,未知则如实标注。
- 无法读取的设备、缺失扩展、未知数据块和无效校验必须明确展示,不猜测。
- EDID 表示接收端声明能力,不能证明当前链路色深、HDR、HDCP 或真实带宽。
- 解析器与 UI、Windows 读取分离;读取在后台线程完成。
- 添加解析边界/错误数据测试。打包后验证实际可见窗口、缩放、刷新和导入导出流程。
- 第一版不完整解码 DisplayID、HDMI Forum VRR/DSC;保留原始块供分析。
## 进度记录
- **v0.1.0(2026-09-25,已发布)**:目录对齐 DLPilot 三区结构;onefile 打包
(`src/build_exe.py` 生成 spec,剔除 PATH 上 Poppler/Conda 的版本化 ICU);Light
主题三件套修复(setTheme + QPalette/应用级 QSS + DWM 标题栏);右上角"关于";
标签 `EDID-Viewer-v0.1.0` 与 Release 附件已核对(66,483,477 字节)。
- 验证明细见 `VALIDATION.md`;实机验收缺口:HDMI 热插拔、多屏、跨屏 DPI、
最大化/恢复与导入导出完整交互流程。
- 根目录 `agent.md` 为仓库整体的开发需求与进度管理文档。
## 后续建议
- EDID 对比、字节差异与固件数组导出。
- HDMI Forum VRR/ALLM/FRL/DSC、DisplayID 与高级色彩数据解析。
- 与实际输出时序和桥接芯片寄存器联合诊断。
+65
View File
@@ -0,0 +1,65 @@
# EDID Viewer
Windows 10/11 显示器 EDID 查看器。使用 **PySide6 / PySide6-Fluent-Widgets**,只读,不修改显示设置。
> 只想使用工具:到 [Gitea Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases)
> 下载 `EDID-Viewer-vX.Y.Z-win64.zip`,解压双击 `EDID-Viewer.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
## 使用
- 双击 `release\EDID-Viewer.exe`(单文件,免安装)。
- 或双击 `start.cmd` 使用本机 Python 环境运行源码。
- 右上角「外观」选择 Light(浅色)、Dark(琥珀深色)、dark1(蓝色深色)、dark2(青色深色)或跟随系统;默认跟随系统,选择自动保存,系统模式响应 Windows 明暗变化。
- 图标沿用 DLPilot 的黑底/橙色标签/白字语言,文字为 EDID / Viewer;窗口、任务栏和 EXE 使用同一图标。
- 当前布局采用 Fluent Widgets:顶部设备/文件操作卡片、右上角「关于」,下方信息卡片和分组页签;Dark 使用琥珀强调色,dark1 使用蓝色强调色,dark2 使用青色强调色,主题随系统设置持久化。
- 点击「刷新设备」,从下拉框选择设备。HDMI、DP 和内屏均显示连接类型。
- 「设备与能力」显示身份和音频/HDR/色彩/HDMI 信息;「分辨率与时序」可展开 DTD 查看像素时钟、前后肩、同步宽度和极性。
- 「原始 HEX」支持选中复制。「导出 BIN」保持读取到的字节不变;「导出报告」生成含原始数据的 UTF-8 JSON。
- 支持 `.bin/.edid` 二进制和 `.hex/.txt` 纯十六进制。HEX 可以使用空格、逗号、`0x` 前缀,不接受带地址/ASCII 栏的 hexdump。
## 目录结构(对齐 DLPilot)
- `src/` — 人写代码:界面(main.py)、EDID 解析(edid.py)、Windows 读取(windows_edid.py)、主题(theme.py)、解析测试(test_edid.py)和打包流水线。
- `assets/` — 图标与生成脚本(可再生)。
- `release/` — 交付物:`EDID-Viewer.exe` 入库;发布 zip 只上传 Gitea Release,不入库。
- `AGENTS.md` — 工程约定与任务边界(改代码前必读);`VALIDATION.md` — 验证记录。
## 环境与构建
本机:Python 3.14、PySide6 6.11.2、PySide6-Fluent-Widgets 1.11.3、PyInstaller 6.22.2。当前 `.venv` 通过 `--system-site-packages` 复用已安装依赖,不修改全局包。新电脑可以创建独立环境:
```powershell
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
.\.venv\Scripts\python.exe src\main.py
cd src && python -m unittest # 9 项解析测试
python src\build_exe.py # 打包 → release\EDID-Viewer.exe
python src\package_release.py 0.1.0 # 组装 release\EDID-Viewer-v0.1.0-win64.zip
python src\publish_gitea.py ..\release\EDID-Viewer-v0.1.0-win64.zip --insecure
```
打包为单文件 exe;spec 由 `build_exe.py` 生成到 `src/build/`(不入库),其中剔除 PATH 上其他工具(Poppler/Conda)的版本化 ICU DLL,避免 QtCore 加载失败(WinError 127)。构建前关闭正在运行的 EDID Viewer,以免文件被占用。图标源为 `assets/make_icon.py`,重新生成时需安装 Pillow(仅开发时需要)。主题配置保存在当前用户的 `QSettings: LT8619C-Tools/EDID-Viewer`。
## 能力范围
- EDID 1.x 基础身份、生产日期、名称/序列号、物理尺寸、Gamma、色深、色度坐标。
- 已建立/标准时序、基础与 CTA 详细时序;常用 CTA VIC(未映射的 VIC 显示编号)。
- 多个 CTA 扩展、音频 SAD、HDMI VSDB、CEC 地址、深色、TMDS、HF-VSDB 的 SCDC/TMDS。
- HDR EOTF、色彩标准、YCbCr 4:2:0 声明。未知块原样保留。
- 每块校验和、扩展计数、截断和数据块越界诊断。
## 限制
- 读取 `root/wmi:WmiMonitorDescriptorMethods` 中的活动设备,不保证列出禁用输出/驱动不提供 WMI 的设备。失败会显示原因,可改用离线文件。
- 数据来自 Windows/显卡驱动,可能是缓存或覆盖内容,不是实时 DDC 抓取。读取扩展失败时保留已经取得的块并报告缺失。
- EDID 能力不等于当前输出模式;不能据此确定 HDCP、线材质量、正在使用的 HDR/色深或 HDMI 版本。
- DisplayID 和 HDMI Forum VRR/ALLM/FRL/DSC 尚未详细解码;仅包含 DisplayID 时序的屏幕可能没有可解码的分辨率。范围描述符扩展偏移仅提示,未应用。
- 不承诺所有 CTA VIC/扩展标签均已覆盖。厂商首先显示标准 PNP 三字母代码,避免错误猜测品牌。
## 推荐后续挖掘
1. **桥接板时序匹配**:像素时钟、H/V 消隐、同步极性和首选模式,排查黑屏、偏色、画面偏移。
2. **EDID 差异比较**:对照板卡固件、Windows 读取值、显示器原始值,定位缓存、截断或改写。
3. **带宽与色彩**:TMDS/FRL、RGB/YCbCr、深色和 4:2:0,解释分辨率或刷新率受限。
4. **HDR 与音频**:PQ/HLG、亮度元数据、PCM 位深/采样率/声道和压缩音频能力。
5. **DisplayID / VRR / DSC**:适合高刷新率和高分辨率显示设备,作为下一阶段扩展。
+50
View File
@@ -0,0 +1,50 @@
# 验证记录 — 2026-09-23
- Python 3.14 / PySide6 6.11.2 / PyInstaller 6.22.2。
- 9 个解析测试通过,包括身份字段、标准时序、1080p/1080i DTD、CTA 音频/4K/HDR/CEC、缺失扩展、坏校验、越界、未知扩展;其中包括 1000 份随机畸形数据的无崩溃检查。
- BIN → 带 0x 前缀 HEX → BIN 一致性检查通过。
- `pip check` 通过。
- WMI 实际读取到内屏 SDC / ATNA60HU01-0;取得 256 字节,两个块校验通过;声明 384 字节,读取第三块失败。UI 正确展示读取错误和缺失警告。
- 打包 EXE 实际启动通过,QtCore/QtGui/QtWidgets 绑定文件存在。截图检查确认设备信息、中文、校验、警告均可见。
- 修复 PATH 中 Poppler ICU 被 PyInstaller 错误收集导致的 QtCore DLL 加载失败;`viewer.spec` 排除冲突 ICU,使用 Windows 系统 ICU。
- 修复系统深色主题与浅色内容背景造成的白字不可见问题,使用完整浅色 Qt 调色板。
- 当前没有可用于验证的外接 HDMI 设备;HDMI 热插拔、多屏、跨屏 DPI、最大化/恢复和 GUI 文件对话框完整流程尚未完成交互验收。检查期间窗口发生用户输入/位置变化,没有继续抢占窗口操作。
- DisplayID、高级 HDMI Forum 字段未完整解析,详见 README。
`validation/` 保存本机读取样本和合成 HDMI 测试数据,仅作本地验收材料。
## 主题与图标更新
- 新 EXE 实际启动通过,窗口展示 EDID / Viewer 黑橙白图标。
- 实际通过右上角切换 Light、Dark;截图检查文字、数据树、按钮、选择框的对比度正常。
- 跟随系统在当前 Windows 深色设置下正确显示深色。已连接 Qt `colorSchemeChanged`;未修改用户 Windows 设置进行系统热切换测试。
- Dark 选择写入 QSettings,关闭 EXE 后重新启动,选择和深色外观均保留。
- 最大化后界面正常,随后还原;没有进行跨显示器 DPI 验证。
- 9 项解析测试仍通过;三种主题的调色板检查通过。
- qt-material 2.17 已在 PySide6 环境加载;Light/Dark/System 三种模式样式表生成检查通过。
- Material 版窗口使用左侧设备/离线操作面板和右侧内容卡片,源码启动与解析测试通过;需完成本次打包后的可见 EXE 检查。
- 本次打包 EXE 实际启动通过;可见界面已切换为 Material Amber 主题,侧栏、圆角内容卡片、页签、设备数据和警告均正常显示。
- 实机操作验证 Light 与 Dark 切换,文字和数据树对比度正常;Material 版保留系统跟随选项。
- 修复 Material 选中态对比度:设备/主题下拉框选中项改为深色文字配琥珀色背景;树表格、焦点行、页签和警告提示分别使用可读的深色/浅色组合。源码测试和打包 EXE 均已更新。
- 针对截图反馈再次调整页签选中态:深色使用深灰底/琥珀字,浅色使用浅琥珀底/深琥珀字,并保留底部强调线;Light 打包窗口实测“分辨率与时序”页签可读。
- 针对 HEX/JSON 截图反馈调整 `QPlainTextEdit`/`QTextEdit` 文本选中态:深色使用蓝灰底/浅字,浅色使用浅蓝灰底/深字,避免 qt-material 的亮黄色底/白字组合。源码样式检查和解析测试通过;本次重新打包后窗口启动验证因物理 Escape 中断,未继续进行 UI 操作。
- 主题选择框和下拉列表已加宽至 148/至少 170 px,三种中文选项可完整显示;控件尺寸检查通过,EXE 已重新打包。
# 验证记录 — 2026-09-24
- 新增 `dark1`(`dark_blue.xml`,蓝色强调)和 `dark2`(`dark_cyan.xml`,青色强调)两个深色 Material 主题;原有 Light、Dark、跟随系统仍保留。
- 9 项解析测试、`pip check` 和 Python 编译检查通过;qt-material 2.17 已确认包含两个新主题资源。
- 最新 `dist\EDID-Viewer\EDID-Viewer.exe` 已重新构建并启动,进程响应正常;offscreen 窗口渲染确认两个主题的主题选择框、页签和内容面板均能显示。
- 深色主题的下拉选中项、树表格焦点行、页签、警告和 HEX/JSON 文本选择态均使用按主题的高对比配色,避免亮背景配白字。
- 当前预览使用无 EDID 样本的启动状态;Windows WMI 实际设备读取结果沿用上一条记录,未把本次 offscreen 渲染当作硬件验收。
# Fluent Widgets 重写验证(2026-09-24)
- `PySide6-Fluent-Widgets 1.11.3` 已替换 qt-material;主界面使用 Fluent 卡片、控件和树视图,主题选择持久化并支持跟随系统。
- 9 项 EDID 解析测试、Python 编译检查和离屏窗口启动通过;主题下拉框可切换 system/light/dark/dark1/dark2。
- `build.ps1` 打包通过,`dist\\EDID-Viewer\\_internal\\qfluentwidgets` 资源已收集;离线 EDID 样本可渲染并启用 BIN/JSON 导出。
# 目录重构与 onefile 打包验证(2026-09-24)
- 目录对齐 DLPilot 三区结构:`src/`(代码 + 流水线)、`assets/`(图标)、`release/`(EDID-Viewer.exe + README);`agent.md` 更名 `AGENTS.md`;`build.ps1`/`viewer.spec` 由 `src/build_exe.py`(生成 spec,ICU 排除逻辑内置)取代。
- `src/` 下 9 项解析测试通过;`src/build_exe.py` 单文件打包通过,`release\EDID-Viewer.exe` 启动验证窗口正常、图标与主题(三件套:QPalette + 应用级 QSS + DWM 标题栏)保持上一轮修复效果。
- `src/package_release.py` 组装 zip(exe + README);`src/publish_gitea.py` 由 DLPilot 同名脚本适配(tag 前缀 `EDID-Viewer-v*`),本次未实际上传。
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

+13
View File
@@ -0,0 +1,13 @@
"""Rebuild icon with Pillow (development only); matches the DLPilot brand."""
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
out = Path(__file__).resolve().parent
image = Image.new('RGB', (512,512), 'black')
draw = ImageDraw.Draw(image)
draw.rounded_rectangle((38,64,474,250),radius=34,fill='#ff9900')
font = 'C:/Windows/Fonts/arialbd.ttf'
draw.text((256,157),'EDID',font=ImageFont.truetype(font,134),anchor='mm',fill='black')
draw.text((256,368),'Viewer',font=ImageFont.truetype(font,116),anchor='mm',fill='white')
image.save(out/'edid-viewer.png')
image.save(out/'edid-viewer.ico',sizes=[(16,16),(24,24),(32,32),(48,48),(64,64),(128,128),(256,256)])
+65
View File
@@ -0,0 +1,65 @@
# EDID Viewer
Windows 10/11 显示器 EDID 查看器。使用 **PySide6 / PySide6-Fluent-Widgets**,只读,不修改显示设置。
> 只想使用工具:到 [Gitea Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases)
> 下载 `EDID-Viewer-vX.Y.Z-win64.zip`,解压双击 `EDID-Viewer.exe` 即可(Windows 10/11 x64,免安装、无需 Python)。
## 使用
- 双击 `release\EDID-Viewer.exe`(单文件,免安装)。
- 或双击 `start.cmd` 使用本机 Python 环境运行源码。
- 右上角「外观」选择 Light(浅色)、Dark(琥珀深色)、dark1(蓝色深色)、dark2(青色深色)或跟随系统;默认跟随系统,选择自动保存,系统模式响应 Windows 明暗变化。
- 图标沿用 DLPilot 的黑底/橙色标签/白字语言,文字为 EDID / Viewer;窗口、任务栏和 EXE 使用同一图标。
- 当前布局采用 Fluent Widgets:顶部设备/文件操作卡片、右上角「关于」,下方信息卡片和分组页签;Dark 使用琥珀强调色,dark1 使用蓝色强调色,dark2 使用青色强调色,主题随系统设置持久化。
- 点击「刷新设备」,从下拉框选择设备。HDMI、DP 和内屏均显示连接类型。
- 「设备与能力」显示身份和音频/HDR/色彩/HDMI 信息;「分辨率与时序」可展开 DTD 查看像素时钟、前后肩、同步宽度和极性。
- 「原始 HEX」支持选中复制。「导出 BIN」保持读取到的字节不变;「导出报告」生成含原始数据的 UTF-8 JSON。
- 支持 `.bin/.edid` 二进制和 `.hex/.txt` 纯十六进制。HEX 可以使用空格、逗号、`0x` 前缀,不接受带地址/ASCII 栏的 hexdump。
## 目录结构(对齐 DLPilot)
- `src/` — 人写代码:界面(main.py)、EDID 解析(edid.py)、Windows 读取(windows_edid.py)、主题(theme.py)、解析测试(test_edid.py)和打包流水线。
- `assets/` — 图标与生成脚本(可再生)。
- `release/` — 交付物:`EDID-Viewer.exe` 入库;发布 zip 只上传 Gitea Release,不入库。
- `AGENTS.md` — 工程约定与任务边界(改代码前必读);`VALIDATION.md` — 验证记录。
## 环境与构建
本机:Python 3.14、PySide6 6.11.2、PySide6-Fluent-Widgets 1.11.3、PyInstaller 6.22.2。当前 `.venv` 通过 `--system-site-packages` 复用已安装依赖,不修改全局包。新电脑可以创建独立环境:
```powershell
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
.\.venv\Scripts\python.exe src\main.py
cd src && python -m unittest # 9 项解析测试
python src\build_exe.py # 打包 → release\EDID-Viewer.exe
python src\package_release.py 0.1.0 # 组装 release\EDID-Viewer-v0.1.0-win64.zip
python src\publish_gitea.py ..\release\EDID-Viewer-v0.1.0-win64.zip --insecure
```
打包为单文件 exe;spec 由 `build_exe.py` 生成到 `src/build/`(不入库),其中剔除 PATH 上其他工具(Poppler/Conda)的版本化 ICU DLL,避免 QtCore 加载失败(WinError 127)。构建前关闭正在运行的 EDID Viewer,以免文件被占用。图标源为 `assets/make_icon.py`,重新生成时需安装 Pillow(仅开发时需要)。主题配置保存在当前用户的 `QSettings: LT8619C-Tools/EDID-Viewer`。
## 能力范围
- EDID 1.x 基础身份、生产日期、名称/序列号、物理尺寸、Gamma、色深、色度坐标。
- 已建立/标准时序、基础与 CTA 详细时序;常用 CTA VIC(未映射的 VIC 显示编号)。
- 多个 CTA 扩展、音频 SAD、HDMI VSDB、CEC 地址、深色、TMDS、HF-VSDB 的 SCDC/TMDS。
- HDR EOTF、色彩标准、YCbCr 4:2:0 声明。未知块原样保留。
- 每块校验和、扩展计数、截断和数据块越界诊断。
## 限制
- 读取 `root/wmi:WmiMonitorDescriptorMethods` 中的活动设备,不保证列出禁用输出/驱动不提供 WMI 的设备。失败会显示原因,可改用离线文件。
- 数据来自 Windows/显卡驱动,可能是缓存或覆盖内容,不是实时 DDC 抓取。读取扩展失败时保留已经取得的块并报告缺失。
- EDID 能力不等于当前输出模式;不能据此确定 HDCP、线材质量、正在使用的 HDR/色深或 HDMI 版本。
- DisplayID 和 HDMI Forum VRR/ALLM/FRL/DSC 尚未详细解码;仅包含 DisplayID 时序的屏幕可能没有可解码的分辨率。范围描述符扩展偏移仅提示,未应用。
- 不承诺所有 CTA VIC/扩展标签均已覆盖。厂商首先显示标准 PNP 三字母代码,避免错误猜测品牌。
## 推荐后续挖掘
1. **桥接板时序匹配**:像素时钟、H/V 消隐、同步极性和首选模式,排查黑屏、偏色、画面偏移。
2. **EDID 差异比较**:对照板卡固件、Windows 读取值、显示器原始值,定位缓存、截断或改写。
3. **带宽与色彩**:TMDS/FRL、RGB/YCbCr、深色和 4:2:0,解释分辨率或刷新率受限。
4. **HDR 与音频**:PQ/HLG、亮度元数据、PCM 位深/采样率/声道和压缩音频能力。
5. **DisplayID / VRR / DSC**:适合高刷新率和高分辨率显示设备,作为下一阶段扩展。
+4
View File
@@ -0,0 +1,4 @@
PySide6==6.11.2
PySide6-Fluent-Widgets==1.11.3
pyinstaller==6.22.2
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
把 src/main.py 打包成单文件 exe(工具名: EDID-Viewer)
================================================
与 DLPilot/src/build_exe.py 同一套路: onefile + --noconsole, Python 解释器、
PySide6 和 PySide6-Fluent-Widgets 全部打进一个 exe, 目标机器无需安装依赖。
用法:
python build_exe.py 自动安装 PyInstaller(如缺)并打包
python build_exe.py --debug 额外打一个带控制台的变体
产物: release/EDID-Viewer.exe
spec 由本脚本生成到 src/build/(不入库), 调整打包逻辑直接改这里的模板。
"""
import os
import subprocess
import sys
# 本脚本位于 src/, 打包产物输出到工具根的 release/
SRC = os.path.dirname(os.path.abspath(__file__))
BASE = os.path.dirname(SRC)
RELEASE = os.path.join(BASE, 'release')
WORK = os.path.join(SRC, 'build')
ICO = os.path.join(BASE, 'assets', 'edid-viewer.ico')
SPEC_TEMPLATE = """\
# -*- coding: utf-8 -*-
# 由 build_exe.py 生成, 不要手改; 调整打包逻辑请改 src/build_exe.py。
from PyInstaller.utils.hooks import collect_all
qfluent_datas, qfluent_binaries, qfluent_hidden = collect_all('qfluentwidgets')
a = Analysis(
[r'{entry}'], pathex=[r'{src}'], binaries=qfluent_binaries,
datas=qfluent_datas + {icon_datas}, hiddenimports=qfluent_hidden,
noarchive=False)
# Qt 6.11 使用 Windows 非版本化 ICU 导出; PATH 上 Poppler/Conda 的版本化
# ICU(icuuc.dll/icudt78.dll)会被 PyInstaller 误收, 令 QtCore.pyd 启动报
# WinError 127 —— 打包时剔除, 运行时用系统自带 ICU。
a.binaries = [b for b in a.binaries if b[0].lower() not in ('icuuc.dll', 'icudt78.dll')]
pyz = PYZ(a.pure)
exe = EXE(pyz, a.scripts, a.binaries, a.datas, [], name='{name}',
debug=False, strip=False, upx=False, console={console}{icon_clause})
"""
def ensure_pyinstaller():
try:
import PyInstaller # noqa: F401
return
except ImportError:
pass
print('PyInstaller 未安装, 正在安装...')
subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'pyinstaller'])
def remove_stale(exe_path):
"""删除旧 exe; 若被占用(上次验证的进程还在), 先结束同名进程再重试"""
if not os.path.exists(exe_path):
return
try:
os.remove(exe_path)
return
except PermissionError:
pass
import time
print(f'{exe_path} 被占用, 结束残留进程后重试...')
subprocess.run(['taskkill', '/F', '/IM', os.path.basename(exe_path)],
capture_output=True)
time.sleep(1.0)
os.remove(exe_path)
def build(name, console):
remove_stale(os.path.join(RELEASE, name + '.exe'))
if os.path.exists(ICO):
icon_datas = f"[(r'{ICO}', 'assets')]"
icon_clause = f", icon=r'{ICO}'"
else:
print('警告: 未找到 assets/edid-viewer.ico(先运行 assets/make_icon.py), 本次打包不带图标')
icon_datas, icon_clause = '[]', ''
spec = SPEC_TEMPLATE.format(entry=os.path.join(SRC, 'main.py'), src=SRC,
icon_datas=icon_datas, name=name,
console=console, icon_clause=icon_clause)
os.makedirs(WORK, exist_ok=True)
spec_path = os.path.join(WORK, name + '.spec')
with open(spec_path, 'w', encoding='utf-8') as f:
f.write(spec)
cmd = [sys.executable, '-m', 'PyInstaller', '--noconfirm', '--clean',
'--distpath', RELEASE, '--workpath', WORK, spec_path]
print('\n>>', ' '.join(cmd))
subprocess.check_call(cmd)
out = os.path.join(RELEASE, name + '.exe')
print(f'完成: {out} ({os.path.getsize(out) / 1024 / 1024:.1f} MB)')
def main():
build_console = '--debug' in sys.argv
ensure_pyinstaller()
# 常规版: 双击即用, 无黑框
build('EDID-Viewer', 'False')
if build_console:
# 调试版: 带控制台, 异常栈可见
build('EDID-Viewer-console', 'True')
if __name__ == '__main__':
main()
+157
View File
@@ -0,0 +1,157 @@
"""Bounds-checked EDID 1.x / CTA parser. Unknown fields retain their bytes."""
import hashlib
import re
VICS = {1:'640×480p 60 Hz',2:'720×480p 60 Hz 4:3',3:'720×480p 60 Hz 16:9',
4:'1280×720p 60 Hz',5:'1920×1080i 60 Hz',16:'1920×1080p 60 Hz',
17:'720×576p 50 Hz 4:3',18:'720×576p 50 Hz 16:9',19:'1280×720p 50 Hz',
20:'1920×1080i 50 Hz',31:'1920×1080p 50 Hz',32:'1920×1080p 24 Hz',
33:'1920×1080p 25 Hz',34:'1920×1080p 30 Hz',60:'1280×720p 24 Hz',
61:'1280×720p 25 Hz',62:'1280×720p 30 Hz',63:'1920×1080p 120 Hz',64:'1920×1080p 100 Hz',
93:'3840×2160p 24 Hz',94:'3840×2160p 25 Hz',95:'3840×2160p 30 Hz',
96:'3840×2160p 50 Hz',97:'3840×2160p 60 Hz',98:'4096×2160p 24 Hz',
99:'4096×2160p 25 Hz',100:'4096×2160p 30 Hz',101:'4096×2160p 50 Hz',102:'4096×2160p 60 Hz',
117:'3840×2160p 100 Hz',118:'3840×2160p 120 Hz'}
EST = ['720×400 70 Hz','720×400 88 Hz','640×480 60 Hz','640×480 67 Hz','640×480 72 Hz',
'640×480 75 Hz','800×600 56 Hz','800×600 60 Hz','800×600 72 Hz','800×600 75 Hz',
'832×624 75 Hz','1024×768i 87 Hz','1024×768 60 Hz','1024×768 70 Hz','1024×768 75 Hz',
'1280×1024 75 Hz','1152×870 75 Hz']
def read_file(path):
from pathlib import Path
data = Path(path).read_bytes()
if len(data) > 32768 * 4:
raise ValueError('文件过大:最大 128 KiB')
if Path(path).suffix.lower() in ('.hex', '.txt'):
s = data.decode('utf-8-sig').strip()
s = re.sub(r'0[xX]([0-9a-fA-F]{2})', r'\1', s)
s = re.sub(r'[\s,{};]', '', s)
if not re.fullmatch(r'(?:[0-9a-fA-F]{2})+', s):
raise ValueError('HEX 文件仅接受十六进制字节,不接受偏移地址或 ASCII 栏')
return bytes.fromhex(s)
return data
def hex_dump(raw):
return '\n'.join(f'{i:04X} ' + ' '.join(f'{x:02X}' for x in raw[i:i+16]) +
' |' + ''.join(chr(x) if 32 <= x < 127 else '.' for x in raw[i:i+16]) + '|'
for i in range(0, len(raw), 16))
def dtd(b, source):
clock = int.from_bytes(b[:2], 'little') * 10000
h = b[2] + ((b[4] & 240) << 4); hb = b[3] + ((b[4] & 15) << 8)
v = b[5] + ((b[7] & 240) << 4); vb = b[6] + ((b[7] & 15) << 8)
hf = b[8] + ((b[11] & 192) << 2); hs = b[9] + ((b[11] & 48) << 4)
vf = (b[10] >> 4) + ((b[11] & 12) << 2); vs = (b[10] & 15) + ((b[11] & 3) << 4)
interlace = bool(b[17] & 128)
total_v = (v + vb) * 2 + 1 if interlace else v + vb
rate = clock / ((h + hb) * total_v) if h + hb and total_v else 0
sync = (b[17] >> 3) & 3
polarity = ('H' + ('+' if b[17] & 2 else '-') + ' V' + ('+' if b[17] & 4 else '-')) if sync == 3 else '非数字分离同步'
return {'来源':source, '分辨率':f'{h}×{v*2 if interlace else v}' + ('i' if interlace else 'p'),
'刷新率':f'{rate*2 if interlace else rate:.3f} Hz' + ('(场率)' if interlace else ''),
'像素时钟 MHz':clock / 1e6, '水平有效/前肩/同步/后肩':[h,hf,hs,hb-hf-hs],
'垂直有效/前肩/同步/后肩(隔行按场)':[v,vf,vs,vb-vf-vs], '同步':polarity}
def parse(raw):
if len(raw) < 128:
raise ValueError(f'EDID 基础块不足 128 字节,实际 {len(raw)}')
r = {'基本信息':{}, '时序':[], '能力':[], '数据块':[], '校验':[], '警告':[]}
info, warnings = r['基本信息'], r['警告']
if raw[:8] != bytes.fromhex('00 ff ff ff ff ff ff 00'):
warnings.append('EDID 文件头无效;以下解析仅供诊断')
if raw[18] != 1:
warnings.append('非 EDID 1.x:未解析,仅保留原始数据')
return r
if len(raw) % 128: warnings.append('末尾存在不足 128 字节的数据')
expected = (raw[126] + 1) * 128
if expected != len(raw): warnings.append(f'声明 {expected} 字节,实际 {len(raw)} 字节')
for n in range(len(raw)//128):
block = raw[n*128:(n+1)*128]
r['校验'].append(f'块 {n}: ' + ('通过' if sum(block)%256 == 0 else '失败'))
pnp = int.from_bytes(raw[8:10], 'big')
manufacturer = ''.join(chr(((pnp >> s) & 31) + 64) for s in (10,5,0))
info.update({'厂家 PNP 代码':manufacturer, '产品编号':f'0x{int.from_bytes(raw[10:12], "little"):04X}',
'数字序列号':int.from_bytes(raw[12:16], 'little'), 'EDID 版本':f'{raw[18]}.{raw[19]}',
'生产日期':f'{raw[17]+1990} 年 / '+('型号年份' if raw[16]==255 else f'第 {raw[16]} 周' if raw[16] else '周未指定'),
'尺寸':f'{raw[21]} × {raw[22]} cm' if raw[21] and raw[22] else '未给出完整物理尺寸(可能编码宽高比)',
'Gamma':(raw[23]+100)/100 if raw[23]!=255 else '未定义',
'扩展块数':raw[126], 'SHA-256':hashlib.sha256(raw).hexdigest()})
if raw[20]&128:
info['输入类型'] = '数字'
if raw[19]>=4:
info['接口声明'] = {0:'未定义',1:'DVI',2:'HDMI-A',3:'HDMI-B',4:'MDDI',5:'DisplayPort'}.get(raw[20]&15,'保留')
info['色深 bpc'] = {0:'未定义',1:6,2:8,3:10,4:12,5:14,6:16}.get((raw[20]>>4)&7,'保留')
else: info['输入类型'] = '模拟'
for idx, mode in enumerate(EST):
if raw[35+idx//8] & (128>>(idx%8)): r['时序'].append({'来源':'已建立时序','分辨率':mode})
for i in range(38,54,2):
a,b = raw[i:i+2]
if (a,b) in ((1,1),(0,0)): continue
h = (a+31)*8
ratio = (1 if raw[19]<3 else 10/16, 3/4, 4/5, 9/16)[b>>6]
r['时序'].append({'来源':'标准时序','分辨率':f'{h}×{round(h*ratio)}','刷新率':f'{(b&63)+60} Hz'})
for offset in range(54,126,18):
b=raw[offset:offset+18]
if any(b[:2]):
r['时序'].append(dtd(b, '基础 DTD'+('(首选)' if offset==54 and (raw[19]>=4 or raw[24]&2) else '')))
elif b[:3]==b'\0\0\0':
if b[3] in (252,255,254): info[{252:'显示器名称',255:'文本序列号',254:'描述文本'}[b[3]]] = b[5:18].decode('ascii','replace').strip('\x00\n ')
elif b[3]==253:
info['频率范围原始值'] = f'垂直 {b[5]}–{b[6]} Hz;水平 {b[7]}–{b[8]} kHz;最大像素时钟 {b[9]*10} MHz'
if b[4]: warnings.append('范围描述符有扩展偏移,频率范围显示原始字段,暂未应用偏移')
coords=[]
low=[(raw[25]>>s)&3 for s in (6,4,2,0)]+[(raw[26]>>s)&3 for s in (6,4,2,0)]
for i,name in enumerate(('Rx','Ry','Gx','Gy','Bx','By','Wx','Wy')): coords.append(f'{name}={(raw[27+i]*4+low[i])/1024:.4f}')
r['能力'].append('色度坐标:'+', '.join(coords))
for n in range(1,min(len(raw)//128,raw[126]+1)):
b=raw[n*128:(n+1)*128]
r['数据块'].append({'块':n,'类型':{2:'CTA-861',112:'DisplayID(暂未解码)'}.get(b[0],f'未知 0x{b[0]:02X}'),'原始':b.hex(' ')})
if b[0]!=2: continue
r['能力'].append(f'CTA 块 {n} / revision {b[1]}:'+', '.join(label for bit,label in ((64,'基本音频'),(32,'YCbCr 4:4:4'),(16,'YCbCr 4:2:2')) if b[3]&bit))
end=b[2]
if end==0: continue
if not 4<=end<=127:
warnings.append(f'CTA 块 {n} DTD 偏移无效:{end}'); continue
p=4
while p<end:
tag,length=b[p]>>5,b[p]&31
if not b[p]: break
if p+1+length>end:
warnings.append(f'CTA 块 {n} 数据块越界 @{p}'); break
x=b[p+1:p+1+length]; p+=length+1
entry={'块':n,'tag':tag,'原始':x.hex(' ')}; r['数据块'].append(entry)
if tag==2:
for svd in x:
vic=svd&127 if 129<=svd<=192 else svd
r['时序'].append({'来源':f'CTA VIC {vic}'+('(原生)' if 129<=svd<=192 else ''),'分辨率':VICS.get(vic,'尚未映射;查看 VIC 编号')})
elif tag==1:
if len(x)%3: warnings.append('音频数据块长度不是 3 的倍数')
for j in range(0,len(x)-2,3):
a,f,c=x[j:j+3]; fmt=(a>>3)&15
rates='/'.join(str(v) for k,v in enumerate((32,44.1,48,88.2,96,176.4,192)) if f&(1<<k))
bits='/'.join(str(v) for k,v in enumerate((16,20,24)) if c&(1<<k))
r['能力'].append(f'音频:'+{1:'LPCM',2:'AC-3',7:'DTS',10:'E-AC-3',11:'DTS-HD',12:'MAT'}.get(fmt,f'格式代码 {fmt}')+f' / {(a&7)+1} 声道 / {rates} kHz'+(f' / {bits} bit' if fmt==1 else f' / 格式字段 0x{c:02X}'))
elif tag==3 and len(x)>=3:
oui=int.from_bytes(x[:3],'little'); entry['OUI']=f'{oui:06X}'
if oui==0x000c03:
r['能力'].append('HDMI VSDB')
if len(x)>=5: r['能力'].append('CEC 物理地址:'+'.'.join(str(v) for v in (x[3]>>4,x[3]&15,x[4]>>4,x[4]&15)))
if len(x)>=6: r['能力'].append('HDMI 深色:'+('/'.join(str(v) for k,v in ((4,10),(5,12),(6,16)) if x[5]&(1<<k)) or '未声明')+' bpc')
if len(x)>=7 and x[6]: r['能力'].append(f'最大 TMDS 时钟:{x[6]*5} MHz')
elif oui==0xc45dd8:
r['能力'].append('HDMI Forum VSDB(VRR/FRL/DSC 字段暂未解码)')
if len(x)>=5 and x[4]: r['能力'].append(f'最大 TMDS 字符速率:{x[4]*5} MHz')
if len(x)>=6: r['能力'].append('SCDC:'+('支持' if x[5]&128 else '未声明'))
elif tag==7 and x:
entry['扩展 tag']=x[0]
if x[0]==6 and len(x)>=3:
r['能力'].append('HDR EOTF:'+', '.join(name for k,name in enumerate(('传统 SDR','传统 HDR','PQ / ST 2084','HLG')) if x[1]&(1<<k)))
r['能力'].append(f'HDR 静态元数据类型位图:0x{x[2]:02X}')
elif x[0]==14:
r['能力'].append('仅 YCbCr 4:2:0 VIC:'+', '.join(f'{v} ({VICS.get(v,"未映射")})' for v in x[1:]))
elif x[0]==15: r['能力'].append('YCbCr 4:2:0 能力映射:'+x[1:].hex(' ')+'(按本 CTA SVD 顺序)')
elif x[0]==5 and len(x)>=3:
r['能力'].append('色彩标准:'+', '.join(name for k,name in enumerate(('xvYCC601','xvYCC709','sYCC601','opYCC601','opRGB','BT.2020 cYCC','BT.2020 YCC','BT.2020 RGB')) if x[1]&(1<<k)))
for o in range(end,110,18):
if any(b[o:o+2]): r['时序'].append(dtd(b[o:o+18],f'CTA {n} DTD'))
return r
+391
View File
@@ -0,0 +1,391 @@
"""Fluent EDID viewer for Windows display devices and offline files."""
import ctypes
import json
import sys
from pathlib import Path
from PySide6.QtCore import QThread, Qt, QTimer, Signal
from PySide6.QtGui import QFont, QFontDatabase, QIcon
from PySide6.QtWidgets import (
QApplication,
QFileDialog,
QHBoxLayout,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QSizePolicy,
QSplitter,
QTabWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from qfluentwidgets import (
BodyLabel,
CaptionLabel,
CardWidget,
ComboBox,
FluentIcon,
PlainTextEdit,
PrimaryPushButton,
PushButton,
StrongBodyLabel,
SubtitleLabel,
TreeWidget,
)
from edid import hex_dump, parse, read_file
from theme import ThemeManager
from windows_edid import scan
APP_NAME = "EDID Viewer"
APP_VERSION = "0.1.0"
REPO_URL = "https://gitea.hulk.wang/hulk/HW-Debug-Tools"
def icon_path():
"""Packaged onefile: icon is extracted under sys._MEIPASS/assets; source: ../assets."""
if hasattr(sys, "_MEIPASS"):
return Path(sys._MEIPASS) / "assets" / "edid-viewer.ico"
return Path(__file__).resolve().parent.parent / "assets" / "edid-viewer.ico"
class Scanner(QThread):
result = Signal(object)
failure = Signal(str)
def run(self):
try:
self.result.emit(scan())
except Exception as exc:
self.failure.emit(str(exc))
class Window(QMainWindow):
"""Main Fluent surface; parser and Windows access remain separate modules."""
def __init__(self, theme_manager=None):
super().__init__()
self.theme_manager = theme_manager or ThemeManager(QApplication.instance())
self.setWindowTitle("EDID Viewer · HDMI 显示信息")
self.resize(1240, 820)
self.setMinimumSize(900, 620)
self.raw = b""
self.report = {}
self.worker = None
self._build_ui()
self.theme_manager.set_window(self)
self.bin_button.setEnabled(False)
self.export_button.setEnabled(False)
QTimer.singleShot(0, self.start_scan)
def _button(self, text, callback, primary=False, icon=None):
button = PrimaryPushButton(self) if primary else PushButton(self)
button.setText(text)
if icon is not None:
button.setIcon(icon)
button.clicked.connect(callback)
return button
def _card(self, title, subtitle=None, parent=None):
card = CardWidget(parent or self)
layout = QVBoxLayout(card)
layout.setContentsMargins(16, 14, 16, 16)
layout.setSpacing(9)
layout.addWidget(StrongBodyLabel(title, card))
if subtitle:
caption = CaptionLabel(subtitle, card)
caption.setWordWrap(True)
layout.addWidget(caption)
return card, layout
def _text(self, mono=False, parent=None):
text = PlainTextEdit(parent or self)
text.setReadOnly(True)
text.setLineWrapMode(QPlainTextEdit.NoWrap)
if mono:
text.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
return text
def _build_ui(self):
root = QWidget(self)
root.setObjectName("root")
outer = QVBoxLayout(root)
outer.setContentsMargins(22, 18, 22, 18)
outer.setSpacing(14)
header = QHBoxLayout()
title_box = QVBoxLayout()
title_box.setSpacing(1)
title_box.addWidget(SubtitleLabel("EDID Viewer", root))
title_box.addWidget(BodyLabel("显示设备能力与原始数据", root))
header.addLayout(title_box)
header.addStretch(1)
header.addWidget(BodyLabel("外观", root))
self.theme_choice = ComboBox(root)
self.theme_choice.setAccessibleName("外观主题")
for label, value in (
("跟随系统", "system"),
("Light · 浅色", "light"),
("Dark · 深色", "dark"),
("dark1 · 蓝色", "dark1"),
("dark2 · 青色", "dark2"),
):
self.theme_choice.addItem(label)
self.theme_choice.setItemData(self.theme_choice.count() - 1, value)
self.theme_choice.setCurrentIndex(self.theme_choice.findData(self.theme_manager.mode))
self.theme_choice.currentIndexChanged.connect(
lambda: self.theme_manager.set_mode(self.theme_choice.currentData())
)
header.addWidget(self.theme_choice)
header.addWidget(self._button("关于", self.show_about))
outer.addLayout(header)
note = CaptionLabel(
"Windows 驱动提供的 EDID,可能包含缓存或覆盖;EDID 是接收端声明能力,不代表当前实际输出。",
root,
)
note.setWordWrap(True)
outer.addWidget(note)
split = QSplitter(Qt.Vertical, root)
split.setChildrenCollapsible(False)
split.setHandleWidth(8)
split.addWidget(self._build_sidebar())
split.addWidget(self._build_content())
split.setStretchFactor(0, 0)
split.setStretchFactor(1, 1)
split.setSizes([150, 620])
outer.addWidget(split, 1)
self.setCentralWidget(root)
def _build_sidebar(self):
host = QWidget(self)
host.setMinimumHeight(132)
host.setMaximumHeight(210)
layout = QHBoxLayout(host)
layout.setContentsMargins(2, 2, 2, 2)
layout.setSpacing(12)
card, body = self._card("显示设备", "选择活动输出并读取驱动提供的原始 EDID。", host)
card.setMinimumWidth(360)
self.devices = ComboBox(card)
self.devices.setMinimumHeight(38)
self.devices.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
device_row = QHBoxLayout()
device_row.setSpacing(8)
device_row.addWidget(self.devices, 1)
self.refresh_button = self._button("刷新设备", self.start_scan, icon=FluentIcon.SYNC)
self.refresh_button.setMinimumWidth(116)
device_row.addWidget(self.refresh_button)
body.addLayout(device_row)
layout.addWidget(card)
card, body = self._card("离线文件", "适用于 .bin、.edid、.hex 和纯十六进制 .txt。", host)
card.setMinimumWidth(430)
self.open_button = self._button("打开 EDID", self.open_file, primary=True, icon=FluentIcon.FOLDER)
self.bin_button = self._button("导出 BIN", self.save_bin, icon=FluentIcon.DOWNLOAD)
self.export_button = self._button("导出 JSON 报告", self.save_report, icon=FluentIcon.SAVE)
file_row = QHBoxLayout()
file_row.setSpacing(8)
for button in (self.open_button, self.bin_button, self.export_button):
button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
file_row.addWidget(button, 1)
body.addLayout(file_row)
layout.addWidget(card)
layout.setStretch(0, 1)
layout.setStretch(1, 1)
return host
def _build_content(self):
card = CardWidget(self)
layout = QVBoxLayout(card)
layout.setContentsMargins(18, 16, 18, 18)
layout.setSpacing(12)
self.summary = BodyLabel("正在读取设备…", card)
self.summary.setWordWrap(True)
layout.addWidget(self.summary)
self.tabs = QTabWidget(card)
self.tabs.setDocumentMode(True)
self.tree = TreeWidget(card)
self.tree.setHeaderLabels(["项目", "详细信息"])
self.tree.setColumnWidth(0, 260)
self.tree.setAlternatingRowColors(True)
self.tabs.addTab(self.tree, "设备与能力")
self.timings = TreeWidget(card)
self.timings.setHeaderLabels(["来源 / 字段", "分辨率 / 数值", "刷新率"])
self.timings.setColumnWidth(0, 255)
self.timings.setColumnWidth(1, 360)
self.timings.setAlternatingRowColors(True)
self.tabs.addTab(self.timings, "分辨率与时序")
self.hex = self._text(mono=True, parent=card)
self.tabs.addTab(self.hex, "原始 HEX")
self.details = self._text(mono=True, parent=card)
self.tabs.addTab(self.details, "完整解析 JSON")
layout.addWidget(self.tabs, 1)
self.devices.currentIndexChanged.connect(self.select_device)
return card
def show_about(self):
QMessageBox.about(
self,
f"关于 {APP_NAME}",
f"{APP_NAME} v{APP_VERSION}\n\n"
"Windows 显示器 EDID 查看器 · 只读,不修改显示设置\n"
"PySide6-Fluent-Widgets UI\n\n"
f"{REPO_URL}",
)
def start_scan(self):
if self.worker and self.worker.isRunning():
return
self.refresh_button.setEnabled(False)
self.open_button.setEnabled(False)
self.statusBar().showMessage("正在读取活动设备…")
self.worker = Scanner(self)
self.worker.result.connect(self.scanned)
self.worker.failure.connect(self.scan_failed)
self.worker.finished.connect(lambda: self.refresh_button.setEnabled(True))
self.worker.finished.connect(lambda: self.open_button.setEnabled(True))
self.worker.start()
def scan_failed(self, message):
self.statusBar().showMessage("设备读取失败,可打开离线 EDID 文件")
self.summary.setText("Windows EDID 读取失败:" + message)
def scanned(self, items):
old = self.devices.currentData()
old_id = old.get("instance") if old else None
self.devices.blockSignals(True)
self.devices.clear()
selected = 0
for index, item in enumerate(items):
name = item["instance"]
try:
info = parse(item["raw"])["基本信息"]
name = info.get("显示器名称", info.get("厂家 PNP 代码", name))
except ValueError:
pass
self.devices.addItem(
f'{item["连接"]} · {name} · {item["instance"]}',
userData=item,
)
if item["instance"] == old_id:
selected = index
self.devices.setCurrentIndex(selected)
self.devices.blockSignals(False)
self.select_device()
self.statusBar().showMessage(f"找到 {len(items)} 个活动设备;未启用的输出可能不会列出")
def select_device(self, *_):
item = self.devices.currentData()
if item:
self.show_raw(item["raw"], item["instance"] + " / " + item["连接"], item["error"])
else:
self.show_raw(b"", "未找到活动显示器。可刷新或打开离线文件。")
def show_raw(self, raw, source, error=""):
self.raw = raw
self.report = {}
self.tree.clear()
self.timings.clear()
self.details.clear()
self.hex.setPlainText(hex_dump(raw))
self.bin_button.setEnabled(bool(raw))
self.export_button.setEnabled(False)
try:
report = parse(raw)
except ValueError as exc:
self.summary.setText(f"{source}\n{error or str(exc)}")
return
self.report = {"来源": source, "读取错误": error, **report, "原始 HEX": raw.hex(" ")}
self.export_button.setEnabled(True)
messages = report["警告"] + [item for item in report["校验"] if "失败" in item]
if error:
messages.insert(0, error)
status = "各块校验通过" if not messages else "⚠ " + ";".join(messages)
self.summary.setText(f"{source}\n{len(raw)} 字节 · {status}")
for group in ("基本信息", "能力", "校验", "警告", "数据块"):
parent = QTreeWidgetItem(self.tree, [group])
value = report[group]
rows = value.items() if isinstance(value, dict) else enumerate(value, 1)
for key, item in rows:
rendered = json.dumps(item, ensure_ascii=False) if isinstance(item, (dict, list)) else str(item)
QTreeWidgetItem(parent, [str(key), rendered])
parent.setExpanded(group != "数据块")
for timing in report["时序"]:
parent = QTreeWidgetItem(self.timings, [timing["来源"], timing["分辨率"], timing.get("刷新率", "")])
for key, value in timing.items():
if key not in ("来源", "分辨率", "刷新率"):
QTreeWidgetItem(parent, [key, str(value)])
self.details.setPlainText(json.dumps(self.report, ensure_ascii=False, indent=2))
def open_path(self, path):
try:
raw = read_file(path)
self.devices.blockSignals(True)
self.devices.setCurrentIndex(-1)
self.devices.blockSignals(False)
self.show_raw(raw, "文件:" + str(path))
except (OSError, ValueError, UnicodeError) as exc:
QMessageBox.warning(self, "无法打开", str(exc))
def open_file(self):
path, _ = QFileDialog.getOpenFileName(
self,
"打开 EDID",
"",
"EDID (*.bin *.edid *.hex *.txt);;全部文件 (*)",
)
if path:
self.open_path(path)
def save_bin(self):
path, _ = QFileDialog.getSaveFileName(self, "导出原始 EDID", "edid.bin", "BIN (*.bin)")
if path:
self.write(path, self.raw)
def save_report(self):
path, _ = QFileDialog.getSaveFileName(self, "导出报告", "edid-report.json", "JSON (*.json)")
if path:
self.write(path, json.dumps(self.report, ensure_ascii=False, indent=2).encode("utf-8"))
def write(self, path, data):
try:
Path(path).write_bytes(data)
self.statusBar().showMessage("已保存:" + path)
except OSError as exc:
QMessageBox.warning(self, "保存失败", str(exc))
def closeEvent(self, event):
if self.worker and self.worker.isRunning():
self.statusBar().showMessage("正在读取,请等待读取完成后关闭(最长约 45 秒)")
event.ignore()
else:
event.accept()
def main():
if sys.platform == "win32":
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("LT8619C.Tools.EDIDViewer")
app = QApplication(sys.argv)
app.setApplicationName(APP_NAME)
app.setFont(QFont("Microsoft YaHei UI", 10))
app.setWindowIcon(QIcon(str(icon_path())))
theme_manager = ThemeManager(app)
window = Window(theme_manager)
window.show()
return app.exec()
if __name__ == "__main__":
sys.exit(main())
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
组装 EDID-Viewer 发布包: exe + README → 一个 zip(解压即用)
================================================
把主 README 复制进 release/ 后, 与单文件 exe 一起压成 zip —— 两者内容一致。
zip 解压后根目录为 EDID-Viewer/:
EDID-Viewer.exe 主程序(先运行 build_exe.py 打包)
README.md 使用说明
用法:
python package_release.py 版本号自动取最近 git tag(EDID-Viewer-v*)
python package_release.py 0.1.0 手动指定版本号
产物: release/EDID-Viewer-v<版本>-win64.zip(zip 不入库, 上传到 Gitea Release)
"""
import os
import shutil
import subprocess
import sys
import zipfile
SRC = os.path.dirname(os.path.abspath(__file__))
BASE = os.path.dirname(SRC)
RELEASE = os.path.join(BASE, 'release')
def detect_version():
"""从最近的 EDID-Viewer-v* tag 推断版本号, 无 tag 则报错提示手动指定"""
try:
out = subprocess.check_output(
['git', 'describe', '--tags', '--abbrev=0', '--match', 'EDID-Viewer-v*'],
cwd=BASE, stderr=subprocess.DEVNULL)
return out.decode().strip().removeprefix('EDID-Viewer-v')
except Exception:
sys.exit('未能从 git tag 推断版本号, 请手动指定: python package_release.py <版本号>')
def main():
version = (sys.argv[1] if len(sys.argv) > 1 else detect_version()).lstrip('v')
exe = os.path.join(RELEASE, 'EDID-Viewer.exe')
readme = os.path.join(BASE, 'README.md')
if not os.path.exists(exe):
sys.exit('缺少 release/EDID-Viewer.exe, 先运行: python build_exe.py')
if not os.path.exists(readme):
sys.exit(f'缺少 {readme}')
shutil.copy2(readme, os.path.join(RELEASE, 'README.md'))
zip_name = f'EDID-Viewer-v{version}-win64.zip'
zip_path = os.path.join(RELEASE, zip_name)
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z:
z.write(exe, f'EDID-Viewer/{os.path.basename(exe)}')
z.write(os.path.join(RELEASE, 'README.md'), 'EDID-Viewer/README.md')
print(f'发布文件夹: {RELEASE} (exe + README)')
print(f'发布包: {zip_path} ({os.path.getsize(zip_path) / 1024 / 1024:.1f} MB)')
print('下一步上传: python publish_gitea.py', zip_name)
if __name__ == '__main__':
main()
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
创建/更新 Gitea Release 并上传发布 zip(纯标准库, 无需安装依赖)
================================================================
用法:
python publish_gitea.py EDID-Viewer-v0.1.0-win64.zip
python publish_gitea.py <zip> --notes-file release/notes-v0.1.0.md
python publish_gitea.py <zip> --tag EDID-Viewer-v0.1.0 --replace
行为:
- tag 与版本默认取 zip 文件名(EDID-Viewer-vX.Y.Z-win64.zip → EDID-Viewer-vX.Y.Z)
- release 不存在则创建(tag 不存在时 Gitea 在默认分支头自动建 tag);
已存在则复用, 仅上传/更新资产
- release 上已有同名资产时: 默认跳过, --replace 删旧再传
- API token: --token 参数或环境变量 GITEA_TOKEN(需该仓库写权限)
"""
import argparse
import json
import os
import re
import ssl
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
DEFAULT_NOTES = """## EDID-Viewer {version}
Windows 显示器 EDID 查看器(LT8619C 调试工具集配套): 只读查看活动显示设备
或离线文件的 EDID —— 厂家/序列号/分辨率时序、CTA/HDMI/音频/HDR 能力、
校验诊断, 支持 BIN/HEX 导入与 BIN/JSON 报告导出。
### 安装
下载 `EDID-Viewer-v{version}-win64.zip`, 解压后双击 `EDID-Viewer.exe`(Windows 10/11 x64, 免安装)。
### 包内容
- `EDID-Viewer.exe` — 主程序
- `README.md` — 完整使用说明
"""
def git_remote_info():
"""从 origin 远程地址解析 API base 与 owner/repo(ssh 端口不影响 https API)"""
url = subprocess.check_output(['git', 'remote', 'get-url', 'origin']).decode().strip()
m = re.match(r'(?:ssh://git@|git@)([\w.-]+)(?::\d+)?[:/]([\w.-]+)/([\w.-]+?)(?:\.git)?$', url)
if not m:
sys.exit(f'无法从 origin 解析 Gitea 地址: {url}')
host, owner, repo = m.groups()
return f'https://{host}', owner, repo
class Api:
def __init__(self, base, owner, repo, token, insecure):
self.api = f'{base}/api/v1/repos/{owner}/{repo}'
self.headers = {'Authorization': f'token {token}'}
self.ctx = ssl._create_unverified_context() if insecure else None
def request(self, method, path, data=None, headers=None, retries=4):
"""发请求; 实例 TLS 间歇性抽风(SSL UNEXPECTED_EOF), 连接级错误自动重试"""
last = None
for attempt in range(retries):
req = urllib.request.Request(self.api + path, data=data, method=method,
headers={**self.headers, **(headers or {})})
try:
with urllib.request.urlopen(req, context=self.ctx) as resp:
body = resp.read()
return resp.status, json.loads(body) if body else None
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b'{}')
except (urllib.error.URLError, ssl.SSLError, OSError) as e:
last = e
time.sleep(1.0 + attempt)
raise RuntimeError(f'Gitea 请求连续 {retries} 次失败: {last}')
def get_json(self, path):
status, data = self.request('GET', path)
return data if status == 200 else None
def upload_asset(self, release_id, filepath, replace):
name = os.path.basename(filepath)
status, rel = self.request('GET', f'/releases/{release_id}')
if status != 200:
sys.exit(f'读取 release {release_id} 失败: {status} {rel}')
# Gitea 版本差异: assets 可能是 {items:[...]} 或纯数组
assets = rel.get('assets') or []
if isinstance(assets, dict):
assets = assets.get('items', [])
for asset in assets:
if asset['name'] == name:
if not replace:
print(f'资产 {name} 已存在, 跳过(覆盖请加 --replace)')
return
self.request('DELETE', f"/releases/{release_id}/assets/{asset['id']}")
print(f'已删除旧资产 {name}')
boundary = uuid.uuid4().hex
payload = open(filepath, 'rb').read()
body = (f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'
f'Content-Type: application/octet-stream\r\n\r\n').encode() + payload + \
f'\r\n--{boundary}--\r\n'.encode()
status, data = self.request(
'POST', f'/releases/{release_id}/assets?name={name}', body,
{'Content-Type': f'multipart/form-data; boundary={boundary}'})
if status != 201:
sys.exit(f'上传资产失败: {status} {data}')
print(f'已上传资产: {name} ({len(payload) / 1024 / 1024:.1f} MB)')
def main():
ap = argparse.ArgumentParser()
ap.add_argument('zipfile_path', help='发布 zip(release/ 下)')
ap.add_argument('--tag', help='release 标签, 默认取 zip 文件名去掉 -win64.zip')
ap.add_argument('--notes-file', help='release 说明 markdown 文件, 默认用内置模板')
ap.add_argument('--token', default=os.environ.get('GITEA_TOKEN'), help='API token')
ap.add_argument('--replace', action='store_true', help='覆盖已存在的同名资产')
ap.add_argument('--insecure', action='store_true', help='跳过 TLS 证书校验(自签证书)')
args = ap.parse_args()
if not os.path.exists(args.zipfile_path):
sys.exit(f'找不到 {args.zipfile_path}')
if not args.token:
sys.exit('缺少 API token: 传 --token 或设环境变量 GITEA_TOKEN')
tag = args.tag or re.sub(r'-win64\.zip$', '', os.path.basename(args.zipfile_path))
version = tag.removeprefix('EDID-Viewer-v')
notes = open(args.notes_file, encoding='utf-8').read() if args.notes_file \
else DEFAULT_NOTES.format(version=version)
base, owner, repo = git_remote_info()
api = Api(base, owner, repo, args.token, args.insecure)
print(f'目标: {base}/{owner}/{repo} tag={tag}')
rel = api.get_json(f'/releases/tags/{tag}')
if rel:
print(f'release 已存在(id={rel["id"]}), 复用')
else:
status, rel = api.request('POST', '/releases', json.dumps({
'tag_name': tag,
'name': f'EDID-Viewer v{version}',
'body': notes,
}).encode(), {'Content-Type': 'application/json'})
if status != 201:
sys.exit(f'创建 release 失败: {status} {rel}')
print(f'release 已创建(id={rel["id"]})')
api.upload_asset(rel['id'], args.zipfile_path, args.replace)
print(f'完成: {rel.get("html_url", "") or f"{base}/{owner}/{repo}/releases/tag/{tag}"}')
if __name__ == '__main__':
main()
+50
View File
@@ -0,0 +1,50 @@
import random
import unittest
from edid import parse,dtd
def checksum(b):
b[127]=(-sum(b[:127]))&255
return bytes(b)
def base(ext=0):
b=bytearray(128); b[:8]=bytes.fromhex('00ffffffffffff00'); b[8:10]=bytes.fromhex('4c2d')
b[18:20]=bytes((1,4)); b[20]=0xa2; b[38:54]=b'\x01\x01'*8; b[126]=ext
b[54:72]=bytes.fromhex('023a801871382d40582c4500fd1e1100001e')
b[72:90]=b'\0\0\0\xfc\0TEST PANEL\n '
return bytearray(checksum(b))
class Tests(unittest.TestCase):
def test_identity_and_dtd(self):
r=parse(base()); self.assertEqual(r['基本信息']['显示器名称'],'TEST PANEL')
self.assertEqual(r['基本信息']['厂家 PNP 代码'],'SAM')
t=r['时序'][0]; self.assertEqual(t['分辨率'],'1920×1080p'); self.assertEqual(t['刷新率'],'60.000 Hz')
self.assertEqual(t['同步'],'H+ V+'); self.assertEqual(t['水平有效/前肩/同步/后肩'],[1920,88,44,148])
def test_standard(self):
b=base(); b[38:40]=bytes((129,192)); r=parse(checksum(b))
self.assertEqual(r['时序'][0]['分辨率'],'1280×720'); self.assertEqual(r['时序'][0]['刷新率'],'60 Hz')
def test_cta(self):
b=bytearray(128); payload=bytes.fromhex('429061 23090707 67030c0012343078 e3060c01')
b[:4]=bytes((2,3,4+len(payload),0x70)); b[4:4+len(payload)]=payload
r=parse(bytes(base(1))+checksum(b)); text=str(r)
self.assertIn('1.2.3.4',text); self.assertIn('PQ / ST 2084',text); self.assertIn('3840×2160p 60 Hz',text)
self.assertIn('600 MHz',text); self.assertIn('LPCM',text)
def test_missing_extension(self):
self.assertTrue(parse(base(2))['警告'])
def test_bad_checksum_and_header(self):
b=base(); b[0]=1; r=parse(b); self.assertTrue(r['警告']); self.assertIn('失败',r['校验'][0])
def test_bounds(self):
with self.assertRaises(ValueError): parse(b'\0'*127)
b=bytearray(128); b[:5]=bytes((2,3,6,0,0x5f))
self.assertIn('越界',str(parse(bytes(base(1))+checksum(b))['警告']))
def test_unknown_extension(self):
b=bytearray(128); b[0]=0x70
self.assertIn('暂未解码',str(parse(bytes(base(1))+checksum(b))))
def test_interlaced(self):
b=bytes.fromhex('011d8018711c1620582c2500fd1e1100009e')
t=dtd(b,'test'); self.assertEqual(t['分辨率'],'1920×1080i'); self.assertEqual(t['刷新率'],'60.000 Hz(场率)')
def test_random_malformed(self):
rng=random.Random(42)
for _ in range(1000):
b=bytearray(rng.randbytes(rng.randint(128,600))); b[18]=1; parse(b)
if __name__=='__main__': unittest.main()
+129
View File
@@ -0,0 +1,129 @@
"""Theme persistence and Fluent Widgets integration."""
import ctypes
import os
from PySide6.QtCore import QObject, QSettings, Qt
from PySide6.QtGui import QColor, QPalette
from qfluentwidgets import Theme, setTheme, setThemeColor
# Fluent widgets restyle themselves on setTheme(); QMainWindow, the native
# QTabWidget/QStatusBar and the Windows title bar keep the OS palette unless
# the palette, an app stylesheet and the DWM title bar attributes follow suit
# (same approach as DLPilot app_fluent.py).
_SURFACES = {
False: {"window": "#f4f6f8", "base": "#ffffff", "text": "#1f2328",
"dim": "#57606a", "border": "#d8dee4", "selected_text": "#ffffff"},
True: {"window": "#202020", "base": "#2b2b2b", "text": "#f5f5f5",
"dim": "#9a9a9a", "border": "#3a3a3a", "selected_text": "#1f2328"},
}
class ThemeManager(QObject):
"""Keep the appearance choice persistent and synchronized with Qt."""
MODES = {"system", "light", "dark", "dark1", "dark2"}
_THEMES = {
"system": Theme.AUTO,
"light": Theme.LIGHT,
"dark": Theme.DARK,
"dark1": Theme.DARK,
"dark2": Theme.DARK,
}
_ACCENTS = {
"light": "#0f6cbd",
"dark": "#f2c94c",
"dark1": "#5aa9e6",
"dark2": "#35c6c8",
}
def __init__(self, app):
super().__init__(app)
self.app = app
self.window = None
self.settings = QSettings("LT8619C-Tools", "EDID-Viewer")
mode = self.settings.value("appearance/theme", "system")
self.mode = mode if mode in self.MODES else "system"
self.apply()
app.styleHints().colorSchemeChanged.connect(self.apply)
def set_window(self, window):
"""Register the main window so the native title bar follows the theme."""
self.window = window
self._apply_titlebar()
def set_mode(self, mode):
if mode not in self.MODES:
raise ValueError(f"Unknown theme: {mode}")
self.mode = mode
self.settings.setValue("appearance/theme", mode)
self.apply()
def is_dark(self):
if self.mode in {"dark", "dark1", "dark2"}:
return True
return self.mode == "system" and self.app.styleHints().colorScheme() == Qt.ColorScheme.Dark
def apply(self, *_):
is_dark = self.is_dark()
setTheme(self._THEMES[self.mode])
setThemeColor(self._ACCENTS.get(self.mode, self._ACCENTS["light"]))
self._apply_palette(is_dark)
self._apply_titlebar()
def _apply_palette(self, is_dark):
"""Give native Qt surfaces the same colors as the Fluent widgets."""
surface = _SURFACES[is_dark]
accent = QColor(self._ACCENTS.get(self.mode, self._ACCENTS["light"]))
palette = QPalette()
for role, color in (
(QPalette.Window, surface["window"]),
(QPalette.Base, surface["base"]),
(QPalette.AlternateBase, surface["base"]),
(QPalette.Text, surface["text"]),
(QPalette.WindowText, surface["text"]),
(QPalette.Button, surface["base"]),
(QPalette.ButtonText, surface["text"]),
(QPalette.PlaceholderText, surface["dim"]),
(QPalette.Highlight, accent),
(QPalette.HighlightedText, surface["selected_text"]),
(QPalette.ToolTipBase, surface["base"]),
(QPalette.ToolTipText, surface["text"]),
):
palette.setColor(role, QColor(color))
self.app.setPalette(palette)
self.app.setStyleSheet(f"""
QMainWindow, QWidget#root {{ background-color: {surface['window']}; color: {surface['text']}; }}
QTabWidget::pane {{ background: {surface['base']}; border: 1px solid {surface['border']}; border-radius: 6px; }}
QTabBar {{ qproperty-drawBase: 0; }}
QTabBar::tab {{ background: transparent; color: {surface['dim']}; padding: 6px 16px;
border: 1px solid transparent; border-top-left-radius: 6px;
border-top-right-radius: 6px; }}
QTabBar::tab:selected {{ background: {surface['base']}; color: {surface['text']};
border: 1px solid {surface['border']}; }}
QTabBar::tab:hover:!selected {{ background: {surface['base']}; }}
QSplitter::handle {{ background: {surface['window']}; }}
QStatusBar {{ background: {surface['window']}; color: {surface['dim']};
border-top: 1px solid {surface['border']}; }}
QToolTip {{ background-color: {surface['base']}; color: {surface['text']};
border: 1px solid {surface['border']}; }}
""")
def _apply_titlebar(self):
"""Ask DWM for a title bar in the current surface color (Win10/11)."""
if self.window is None or os.name != "nt" or not hasattr(ctypes, "windll"):
return
try:
hwnd = int(self.window.winId())
dwm = ctypes.windll.dwmapi
dark = ctypes.c_int(1 if self.is_dark() else 0)
dwm.DwmSetWindowAttribute(hwnd, 20, ctypes.byref(dark), ctypes.sizeof(dark))
surface = _SURFACES[self.is_dark()]
dwm.DwmSetWindowAttribute(hwnd, 35, ctypes.byref(self._colorref(QColor(surface["window"]))), 4)
dwm.DwmSetWindowAttribute(hwnd, 36, ctypes.byref(self._colorref(QColor(surface["text"]))), 4)
except Exception:
pass
@staticmethod
def _colorref(color):
return ctypes.c_int(color.red() | (color.green() << 8) | (color.blue() << 16))
+44
View File
@@ -0,0 +1,44 @@
"""Read active monitors through Windows WMI; never change display settings."""
import json
import subprocess
SCRIPT = r'''
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
$connections = @{}
try { Get-CimInstance -Namespace root/wmi -ClassName WmiMonitorConnectionParams | ForEach-Object { $connections[$_.InstanceName] = [long]$_.VideoOutputTechnology } } catch {}
$result = @(Get-CimInstance -Namespace root/wmi -ClassName WmiMonitorDescriptorMethods | Where-Object Active | ForEach-Object {
$m = $_
$bytes = [System.Collections.Generic.List[byte]]::new()
$failure = ''
try {
$base = Invoke-CimMethod -InputObject $m -MethodName WmiGetMonitorRawEEdidV1Block -Arguments @{BlockId=[byte]0}
if (-not $base.ReturnValue -or $base.BlockContent.Count -ne 128) { throw 'Base EDID read failed' }
$bytes.AddRange([byte[]]$base.BlockContent)
for ($i=1; $i -le [int]$base.BlockContent[126]; $i++) {
$ext = Invoke-CimMethod -InputObject $m -MethodName WmiGetMonitorRawEEdidV1Block -Arguments @{BlockId=[byte]$i}
if (-not $ext.ReturnValue -or $ext.BlockContent.Count -ne 128) { throw "Extension $i read failed" }
$bytes.AddRange([byte[]]$ext.BlockContent)
}
} catch { $failure = $_.Exception.Message }
[pscustomobject]@{instance=$m.InstanceName; connection=$connections[$m.InstanceName]; hex=([BitConverter]::ToString($bytes.ToArray())).Replace('-',''); error=$failure}
})
ConvertTo-Json -InputObject $result -Depth 4 -Compress
'''
CONNECTIONS = {0:'VGA',4:'DVI',5:'HDMI',6:'LVDS 内屏',10:'DisplayPort',11:'eDP 内屏',15:'Miracast',16:'间接有线显示',2147483648:'内部显示'}
def scan():
import base64
command = base64.b64encode(SCRIPT.encode('utf-16le')).decode('ascii')
proc = subprocess.run(['powershell.exe','-NoProfile','-NonInteractive','-EncodedCommand',command],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=45,
creationflags=subprocess.CREATE_NO_WINDOW)
if proc.returncode:
raise RuntimeError(proc.stderr.decode('utf-8','replace').strip())
data = json.loads(proc.stdout.decode('utf-8-sig'))
for item in data:
item['raw'] = bytes.fromhex(item.pop('hex'))
item['连接'] = CONNECTIONS.get(item['connection'],f'未知 ({item["connection"]})')
return data
+3
View File
@@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0"
start "" ".venv\Scripts\pythonw.exe" "src\main.py"
+48
View File
@@ -0,0 +1,48 @@
"""Headless check for the About button and dialog: saves header and dialog PNGs."""
import sys
from pathlib import Path
from PySide6.QtCore import QTimer
from PySide6.QtGui import QFont
from PySide6.QtWidgets import QApplication, QMessageBox
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
import main as m
OUT = Path(__file__).resolve().parent
def snap_dialog():
boxes = [w for w in QApplication.topLevelWidgets() if isinstance(w, QMessageBox)]
if not boxes:
QTimer.singleShot(200, snap_dialog)
return
boxes[0].grab().save(str(OUT / "about-dialog.png"))
boxes[0].close()
def open_about(window):
QTimer.singleShot(600, lambda: snap_dialog())
window.show_about()
def main():
app = QApplication(sys.argv)
app.setApplicationName(m.APP_NAME)
app.setFont(QFont("Microsoft YaHei UI", 10))
theme_manager = m.ThemeManager(app)
window = m.Window(theme_manager)
window.show()
def grab_header():
window.grab().save(str(OUT / "about-header.png"))
open_about(window)
QTimer.singleShot(1500, grab_header)
QTimer.singleShot(6000, app.quit)
sys.exit(app.exec())
if __name__ == "__main__":
main()
+30
View File
@@ -0,0 +1,30 @@
param(
[string]$ProcessName = "python",
[string]$OutPng = "$env:TEMP\edid-window.png"
)
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class Win32Cap {
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
public struct RECT { public int Left, Top, Right, Bottom; }
}
"@
Add-Type -AssemblyName System.Drawing
$proc = Get-Process -Name $ProcessName -ErrorAction Stop |
Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like 'EDID*' } |
Select-Object -First 1
if (-not $proc) { throw "no EDID window found for process '$ProcessName'" }
$hwnd = $proc.MainWindowHandle
[Win32Cap]::SetForegroundWindow($hwnd) | Out-Null
Start-Sleep -Milliseconds 800
$r = New-Object Win32Cap+RECT
[Win32Cap]::GetWindowRect($hwnd, [ref]$r) | Out-Null
$w = $r.Right - $r.Left; $h2 = $r.Bottom - $r.Top
$bmp = New-Object System.Drawing.Bitmap($w, $h2)
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.CopyFromScreen($r.Left, $r.Top, 0, 0, (New-Object System.Drawing.Size($w, $h2)))
$bmp.Save($OutPng, [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
Write-Output "saved $OutPng ($w x $h2)"
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
{
"instance": "DISPLAY\\SDC420B\\4&1387f127&0&UID8388688_0",
"connection": 2147483648,
"error": "常规故障 ",
"连接": "内部显示",
"基本信息": {
"厂家 PNP 代码": "SDC",
"产品编号": "0x420B",
"数字序列号": 0,
"EDID 版本": "1.4",
"生产日期": "2024 年 / 周未指定",
"尺寸": "34 × 22 cm",
"Gamma": 2.2,
"扩展块数": 2,
"SHA-256": "16f168eda782b8d49c3ef5cdf46cb894c353c6ef2570629707b72a0cdefb9584",
"输入类型": "数字",
"接口声明": "DisplayPort",
"色深 bpc": 10,
"频率范围原始值": "垂直 48–240 Hz;水平 177–177 kHz;最大像素时钟 1170 MHz",
"描述文本": "SDC",
"显示器名称": "ATNA60HU01-0"
},
"时序": [],
"能力": [
"色度坐标:Rx=0.6807, Ry=0.3184, Gx=0.2197, Gy=0.7246, Bx=0.1426, By=0.0391, Wx=0.3135, Wy=0.3291"
],
"数据块": [
{
"块": 1,
"类型": "DisplayID(暂未解码)",
"原始": "70 20 79 02 00 20 00 0c ba 41 59 0b 42 00 00 00 00 00 18 00 21 00 1d 74 0d 69 08 00 0a 40 06 00 e5 7a 51 85 a3 b9 4a 02 0a 02 45 54 d0 5f 4c 64 00 45 23 78 26 00 09 06 00 00 00 00 00 50 00 00 22 00 28 80 c5 11 85 ff 09 87 00 07 00 1f 00 3f 06 c7 00 7f 00 07 00 80 c5 11 05 ff 09 87 00 07 00 1f 00 3f 06 df 15 97 15 07 00 81 00 0b e3 05 80 00 e6 06 05 01 8f 6a 02 00 00 00 00 00 f1 90"
}
],
"校验": [
"块 0: 通过",
"块 1: 通过"
],
"警告": [
"声明 384 字节,实际 256 字节",
"范围描述符有扩展偏移,频率范围显示原始字段,暂未应用偏移"
]
}
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x4C, 0x2D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x04, 0xA2, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x3A, 0x80, 0x18, 0x71, 0x38, 0x2D, 0x40, 0x58, 0x2C, 0x45, 0x00, 0xFD, 0x1E, 0x11, 0x00, 0x00, 0x1E, 0x00, 0x00, 0x00, 0xFC, 0x00, 0x54, 0x45, 0x53, 0x54, 0x20, 0x50, 0x41, 0x4E, 0x45, 0x4C, 0x0A, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xC2, 0x02, 0x03, 0x17, 0x70, 0x42, 0x90, 0x61, 0x23, 0x09, 0x07, 0x07, 0x67, 0x03, 0x0C, 0x00, 0x12, 0x34, 0x30, 0x78, 0xE3, 0x06, 0x0C, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xAD
+74
View File
@@ -0,0 +1,74 @@
# I2C-Inject — LT8619C 寄存器择机注入工具
> **⏸ 状态:暂停开发(2026-09-25)。** 工具与固件保留在仓库中不作删除,当前没有继续开发的计划。
> demo 板寄存器语义实测当时即由本工具完成;0x606E 其余位交换经确认(2026-09-25)现状正常、
> 符合预期,无需单独实测。恢复开发前请勿视为完整可用。
> 更新:2026-09-03。由两部分组成:**RP2040 注入器固件**(`mcu-rp2040/`,刷入
> BPI-Pico-RP2040,MicroPython)+ **PC 端 GUI**(`i2c_inject_gui.py` / 打包版
> `I2C-Inject.exe`)。可在 LT8619C demo 板的 I2C 总线上安全地在线读写寄存器。
> 固件细节、刷写步骤与串口命令详见 [mcu-rp2040/README.md](mcu-rp2040/README.md)。
---
## 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)
- 固件分两层:MicroPython 运行时 UF2 + 注入器应用脚本(上传为板上 `main.py` 自启动)
——完整刷写步骤、串口命令表、工作机制见 [mcu-rp2040/README.md](mcu-rp2040/README.md)
- 固件内置 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` 重新打包) |
| `mcu-rp2040/` | RP2040 总线端:注入器固件脚本 + MicroPython 运行时 UF2 + 刷写说明(README) |
| `logos/` | 图标源文件与 `make_ico_i2c.py`(改 logo 后重跑再打包) |
## 6. 故障排查
- 写入回显 `OK 读回=xx` 但稍后读回变了 → demo 板把它刷回了(见 §4 稳态提醒),先确认 HDMI 已稳定出图
- 读寄存器 NAK/失败 → 检查三根接线与共地;用 `s` 命令看边沿计数是否在增长(增长=监听正常,总线有活动)
- 串口列表空白 → 换 USB 口/检查设备管理器(应出现 `USB 串行设备`,VID_2E8A)
+101
View File
@@ -0,0 +1,101 @@
# 0x606E 位交换自动化测试 — 经 RP2040 注入器
# ============================================================
# 流程: 读基线 -> 写各交换位并校验 -> 读回确认 -> 收尾还原
# 注意: LA1010 波形对照按 doc/architecture/rgb_bit_swap_test.md §1.1 手工比对;
# 本脚本负责寄存器侧的全部操作与校验, 打印每步期望的 LA 现象。
# 用法: python bitswap_auto.py [COM口]
# python bitswap_auto.py COM9 full 额外跑 G 通道(0x40)与 R 通道(0x20)
import serial, sys, time
PORT = sys.argv[1] if len(sys.argv) > 1 else 'COM9'
FULL = len(sys.argv) > 2 and sys.argv[2].lower() == 'full'
BANK, REG = '60', '6E'
STEPS = [ # (写值, 含义, 写入后 DLPilot 应投的图, LA 预期变化 vs 基线)
('00', '关闭交换(基准)', 'solid_0000AA / solid_000055',
'D0:AA=13%/55=84%, D1:AA=72%/55=0.9% (基线)'),
('80', 'B通道镜像', 'solid_0000AA',
'与基线互换: D0 应变 ~84%, D1 应变 ~0.9%'),
]
if FULL:
STEPS[1] = ('20', 'R通道镜像(6ch挂D23/D16时测)', 'solid_AA0000',
'D23/D16 占空比互换')
STEPS.append(('40', 'G通道镜像(6ch挂D15/D8时测)', 'solid_00AA00',
'D15/D8 占空比互换'))
STEPS.append(('80', 'B通道镜像', 'solid_0000AA',
'D0/D1 与基线互换'))
def xact(ser, cmd, wait=3.0):
"""发命令收完整回显(注入含重试可能耗时数秒)"""
ser.reset_input_buffer()
ser.write((cmd + '\n').encode())
lines = []
t0 = time.time()
while time.time() - t0 < wait:
ln = ser.readline().decode('utf-8', 'replace').strip()
if ln:
lines.append(ln)
if ln.startswith('=') and ('读回' in ln or ':' in ln):
break
return lines
def check(lines, expect_val):
"""从回显提取 读回=XX 并与期望比较"""
for ln in lines:
if '读回=' in ln:
rb = ln.split('读回=')[1].strip()
ok = rb.upper() == expect_val.upper()
return ok, rb
return False, None
ser = serial.Serial(PORT, timeout=1)
time.sleep(0.5)
print('== 0x606E 位交换自动化 == 端口 %s' % PORT)
print(xact(ser, 's')[0])
log = []
fails = 0
try:
# 0) 通信预检
lines = xact(ser, 'r %s %s' % (BANK, REG))
ok, rb = check(lines, '00')
print('[预检] 读 0x%s%s -> %s (期望默认 00)' % (BANK, REG, rb))
if not ok:
print(' !! 预检值不是 00 —— 若刚做过实验未还原请先还原; 继续执行')
for val, meaning, img, expect in STEPS:
print('\n--- 写 %s (%s) ---' % (val, meaning))
lines = xact(ser, 'w %s %s %s' % (BANK, REG, val), wait=6.0)
for ln in lines:
print(' ' + ln)
ok, rb = check(lines, val)
if not ok:
fails += 1
print(' !! 校验失败(读回 %s != %s), 跳过此步' % (rb, val))
log.append((val, 'FAIL', rb))
continue
log.append((val, 'OK', rb))
print(' [寄存器侧 OK] 读回=%s' % rb)
print(' [现在请投] %s' % img)
print(' [LA 预期] %s' % expect)
if val != '00':
input(' >>> 抓完波形按回车继续...')
# 收尾还原
print('\n--- 收尾: 写 00 还原 ---')
lines = xact(ser, 'w %s %s 00' % (BANK, REG), wait=6.0)
ok, rb = check(lines, '00')
print(' 读回=%s %s' % (rb, 'OK' if ok else 'FAIL'))
if not ok:
fails += 1
finally:
ser.close()
print('\n== 结果汇总 ==')
for val, st, rb in log:
print(' 写%s: %s (读回 %s)' % (val, st, rb))
print('失败步骤: %d' % fails)
+61
View File
@@ -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()
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
I2C-Inject — LT8619C 寄存器择机注入工具
================================================
配合 RP2040 注入器固件(mcu-rp2040/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)
+117
View File
@@ -0,0 +1,117 @@
# RP2040 注入器嗅探数据 -> I2C 协议解码
# 用法: python i2c_sniff_decode.py [COM口] [采集ms, 默认700]
# 帧格式: 固件 'c' 命令回吐 "C <t_us> <val>" 行, val bit0=SDA bit1=SCL
import serial, sys, time
PORT = sys.argv[1] if len(sys.argv) > 1 else 'COM9'
MS = int(sys.argv[2]) if len(sys.argv) > 2 else 700
KNOWN_REGS = {0x44: 'SYS_STATUS', 0x6E: 'TTL_8B_CFG', 0xFF: 'BANK'}
s = serial.Serial(PORT, timeout=1)
time.sleep(0.3)
s.reset_input_buffer()
s.write(b's\n')
time.sleep(0.3)
print(s.read(200).decode('utf-8', 'replace').strip())
s.write(('c %d\n' % MS).encode())
edges = []
t_deadline = time.time() + MS / 1000 + 60
while time.time() < t_deadline:
ln = s.readline().decode('ascii', 'replace').strip()
if ln == 'C.':
break
if ln.startswith('C '):
try:
a, b = ln[2:].split()
edges.append((int(a), int(b)))
except ValueError:
pass
elif ln.startswith('='):
print(ln)
s.close()
print('edges:', len(edges))
# ---------------- I2C 解码 ----------------
trans, cur, bits = [], None, []
psda = pscl = None
def flush_byte():
global bits
if cur is not None and len(bits) == 8:
cur['bytes'].append(int(''.join(str(x) for x in bits), 2))
bits = []
for t, v in edges:
sda, scl = v & 1, (v >> 1) & 1
if pscl is not None:
if scl == 1 and pscl == 1: # SCL 高电平期间 SDA 变化
if psda == 1 and sda == 0: # START / repeated START
if cur is not None:
cur['t1'] = t
cur['inc'] = bool(bits)
trans.append(cur)
cur = {'t0': t, 'bytes': [], 'acks': [], 'inc': False}
bits = []
elif psda == 0 and sda == 1: # STOP
if cur is not None:
cur['t1'] = t
cur['inc'] = bool(bits)
trans.append(cur)
cur = None
bits = []
elif scl == 1 and pscl == 0: # SCL 上升沿采样
if cur is not None:
bits.append(sda)
if len(bits) == 8:
cur['bytes'].append(int(''.join(str(x) for x in bits), 2))
bits = []
elif len(bits) == 9:
cur['acks'].append(sda) # 0=ACK
bits = []
psda, pscl = sda, scl
if cur is not None:
cur['t1'] = edges[-1][0] if edges else 0
cur['inc'] = bool(bits)
trans.append(cur)
# ---------------- 打印 ----------------
bank = None
print(f'\n解码出 {len(trans)} 个事务:')
for k, tr in enumerate(trans):
d = tr['bytes']
if not d:
print(' [%2d] t=%dus <空/片段>' % (k, tr['t0']))
continue
addr7, rw = d[0] >> 1, 'R' if d[0] & 1 else 'W'
acks = tr['acks']
body = d[1:]
ann = []
if addr7 == 0x32 and rw == 'W' and body:
if body[0] == 0xFF and len(body) >= 2:
bank = body[1]
ann.append('bank=%02X' % bank)
body = body[2:]
if body:
r = body[0]
note = KNOWN_REGS.get(r, '')
if r == 0xFF:
bank = body[1] if len(body) > 1 else bank
note = 'bank=%02X' % bank if len(body) > 1 else note
ann.append(('[%02X%02X] %s' % (bank or 0, r, note)) if note else '[%02X?%02X]' % (bank or 0, r))
ackstr = ''.join('A' if a == 0 else 'N' for a in acks[1:]) if len(acks) > 1 else ''
flag = ' <未收尾>' if tr.get('inc') else ''
print(' [%2d] t=%7dus 0x%02X %s: %-30s ACK:%s%s'
% (k, tr['t0'], addr7, rw,
' '.join('%02X' % x for x in d[1:12]) + (' …' if len(d) > 12 else ''),
ackstr[:16], flag))
# 轮询周期统计(相邻事务起始间隔的主值)
if len(trans) > 3:
gaps = [b['t0'] - a['t0'] for a, b in zip(trans, trans[1:])]
gaps = [g for g in gaps if 1000 < g < 5000000]
if gaps:
gaps.sort()
print('\n相邻事务间隔(us): 中位=%d 最小=%d 最大=%d'
% (gaps[len(gaps) // 2], gaps[0], gaps[-1]))
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#000000"/>
<rect x="38" y="64" width="436" height="186" rx="34" fill="#FF9900"/>
<text x="256" y="210" font-family="Arial, 'Microsoft YaHei', sans-serif" font-size="146" font-weight="900" fill="#000000" text-anchor="middle">I2C</text>
<text x="256" y="421" font-family="Arial, 'Microsoft YaHei', sans-serif" font-size="146" font-weight="900" fill="#FFFFFF" text-anchor="middle">Inject</text>
</svg>

After

Width:  |  Height:  |  Size: 507 B

+58
View File
@@ -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}')
+79
View File
@@ -0,0 +1,79 @@
# mcu-rp2040 — RP2040 注入器固件
本目录存放 **I2C-Inject 的总线端**:跑在 BPI-Pico-RP2040(RP2040 + MicroPython)上的
注入器应用脚本,以及配套的 MicroPython 运行时 UF2。
> PC 端 GUI 见上级目录 [../README.md](../README.md)。两者通过 USB 虚拟串口通信。
## 1. 文件说明
| 文件 | 用途 |
|---|---|
| `rp2040_inject.py` | 注入器应用(MicroPython 脚本)。上传为板上 `main.py`,上电自启动 |
| `RPI_PICO-20260824-v1.29.0.uf2` | MicroPython 运行时(标准 RP2040 构建,v1.29.0,2026-08-24)。首次使用或升级时刷 |
⚠️ UF2 必须是 **RPI_PICO** 标准构建(RP2040 芯片);`PICO_W`(带 WiFi 芯片)与
`RPI_PICO2`(RP2350 芯片)变体不可用。
## 2. 硬件与接线
| BPI-Pico-RP2040 | 接 LT8619C 总线 | 说明 |
|---|---|---|
| GP8(物理脚 11) | SDA | 平时高阻输入只听,注入时才驱动 |
| GP9(物理脚 12) | SCL | 同上 |
| GND | GND | 必须共地 |
- **不接 VCC/3V3**(各喂各的电)、**不外加任何上拉电阻**(总线用 demo 板自带上拉)
- 动手前确认总线上拉电平为 3.3V(RP2040 不耐 5V)
## 3. 刷写步骤
```text
① 刷 MicroPython 运行时(首次/升级):
按住板上 BOOTSEL 键插入 USB → 出现 RPI-RP2 U 盘
→ 把 RPI_PICO-20260824-v1.29.0.uf2 拖入 → 自动重启
⚠ 刷 UF2 可能重建板载文件系统, 之后需重传 ② 的 main.py
② 上传注入器应用(每次刷 UF2 后, 或应用更新后):
py -m pip install mpremote
py -m mpremote connect COMx cp rp2040_inject.py :main.py
py -m mpremote connect COMx reset # 复位, main.py 自启动
③ 验证: 串口终端(任意波特率)应看到
LT8619C 注入器就绪 GP8=SDA GP9=SCL 100kHz addr 0x32
```
**LED 状态**:上电快闪 3 次 = 自检;**常亮 = 总线监听中**。
## 4. 串口命令(USB CDC,十六进制参数,一行一令)
| 命令 | 功能 | 回显示例 |
|---|---|---|
| `w <bank> <reg> <val>` | 择机写 + 读回校验 | `= w 60 6E 80 : OK 读回=80` |
| `r <bank> <reg>` | 择机读 | `= r 60 6E : OK 00` |
| `s` | 总线边沿计数(接好线后在涨 = 监听正常) | `= 边沿计数 2958` |
| `c [ms]` | 嗅探捕获(默认 700ms),输出边沿时间戳供离线解码 I2C | `= capture 1207 edges / 700 ms` + `C` 行 |
| `?` | 帮助 | |
- 回显行首:`=` 正常结果 / `!` 告警(校验不符、总线错误、静置超时)
- 读回不符(被 demo 板 MCU 插队)会自动整组重试,最多 3 次
## 5. 工作机制(择机注入)
```
监听 SDA/SCL 边沿(双沿中断) → 等下一轮总线活动出现
→ 活动结束后总线静置满 50ms → 立即发射:
START→[bank 写]→START→[reg/数据 写]→RESTART→[读回]→STOP
→ 成功/失败(重试) → 立即释放总线回监听
```
- demo 板 MCU 以 ~500ms 周期轮询,注入序列 <2ms,落在周期间空窗内冲突概率趋近零
- 嗅探捕获采用 viper 裸指针读 GPIO(~0.27µs/次 ≈ 3MHz 采样),字节码路径仅 ~12µs/次(会混叠)作兜底
## 6. 已知实测行为(demo 板)
- 0x606E:bit5(0x20) 镜像 D[7:0](蓝值组)立即生效;板子非稳态时 0.3~0.5s 内会被其固件刷回 0x00
→ **先投图等稳态再写,写完立即读回确认**
- 0x606D:000/001/010=BGR、011=BRG、100=GBR、101=GRB、110=RBG、111=RGB(字母串=
D[23:16]/D[15:8]/D[7:0] 分量顺序),切换实时生效,不影响 HDMI 锁定
- 过程数据与完整规程见上游主仓库 `doc/architecture/rgb_bit_swap_test.md`
Binary file not shown.
+192
View File
@@ -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 <bank> <reg> <val> 择机写 + 读回校验, 例: w 60 6E 80 (B通道镜像)
# r <bank> <reg> 择机读, 例: 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()
+72 -2
View File
@@ -1,3 +1,73 @@
# LT8619C_Debug_Tools # HW-Debug-Tools
本仓库用存储LT8619C调试过程中需要的一些小工具(包括AI生成) 硬件调试上位机工具集,起家于 LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套,
现收纳全部调试工具;原名 `LT8619C_Debug_Tools`,2026-09-28 更名(Gitea 旧地址自动重定向)。
上游主仓库(固件):[LT8619C_DLPC3421_HDMI](https://gitea.hulk.wang/hulk/LT8619C_DLPC3421_HDMI)。
## 工具一览
| 图标 | 工具 | 用途 | 获取 |
|---|---|---|---|
| <img src="DLPilot/src/logos/logo_dlpilot.png" width="64" alt="DLPilot 图标"/> | [DLPilot](DLPilot/) | HDMI 源端(EDID 查看/逐字节解析、分辨率切换、纯色与测试图案全屏投屏)+ 光机控制(RS485→GD32 网关→DLPC3421:开关光机/设光强/查温度电流) | 到 [Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases) 下 `DLPilot-vX.Y.Z-win64.zip`,解压即用;详见 [DLPilot/README.md](DLPilot/README.md) |
| <img src="EDID-viewer/assets/edid-viewer.png" width="64" alt="EDID-Viewer 图标"/> | [EDID-viewer](EDID-viewer/) | Windows 显示器 EDID 只读查看器:活动设备或离线文件的厂家/序列号/分辨率时序、CTA/HDMI/音频/HDR 能力、校验诊断;BIN/HEX 导入,BIN/JSON 报告导出 | 到 [Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases) 下 `EDID-Viewer-vX.Y.Z-win64.zip`,解压即用;详见 [EDID-viewer/README.md](EDID-viewer/README.md) |
| <img src="SerialMate/assets/serial-mate.png" width="64" alt="SerialMate 图标"/> | [SerialMate](SerialMate/) | RS485/串口调试助手:收发 + 协议包解析(帧构成令牌序列/包头/板ID/LEN口径/CRC 全可定制,默认兼容 DLPilot 光机协议)、板型档案一键切换协议参数与命令预设、payload 字符串/HEX 显示、4 通道曲线(弹窗独立、时间窗滑笔回看、悬停读值、暂停渲染)、发送框输入 payload 自动包装组帧(`/` 分隔多条)、HEX 直发与周期发送 | 到 [Releases](https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases) 下 `SerialMate-vX.Y.Z-win64.zip`,解压即用;详见 [SerialMate/README.md](SerialMate/README.md) |
| <img src="I2C-Inject/logos/logo_i2c_orange.png" width="64" alt="I2C-Inject 图标"/> | [I2C-Inject](I2C-Inject/) | ⏸ **暂停开发**:LT8619C 寄存器在线读写(RP2040 注入器在 demo 板 I2C 总线上择机注入,避让主控轮询,写后自动读回校验)。代码与固件保留备查 | 暂不提供发布;状态与原理详见 [I2C-Inject/README.md](I2C-Inject/README.md) |
## 环境要求
- 发布版 zip 开箱即用(Windows 10/11 x64,无需安装 Python)
- 源码运行:Python ≥3.10;DLPilot 命令行/EDID 纯标准库,GUI 需
`py -m pip install PySide6-Fluent-Widgets`(自定义图片 JPG/BMP 可选 Pillow、光机控制可选 pyserial);
EDID-viewer GUI 需 `py -m pip install -r EDID-viewer/requirements.txt`(PySide6 + PySide6-Fluent-Widgets);
SerialMate GUI 需 `py -m pip install PySide6 PySide6-Fluent-Widgets pyserial`;
I2C-Inject 需 `py -m pip install pyserial`
- 重新打包:`DLPilot/src/build_exe.py`、`EDID-viewer/src/build_exe.py` 与
`SerialMate/src/build_exe.py`(均自动安装 PyInstaller);I2C-Inject 目录内 `python build_exe.py`
## 目录结构
每个工具内统一三区:**src/ 源代码(人写)** → **assets/ 生成资产(可再生成)** →
**release/ 打包与发布产物(机器生成)**;README.md 面向使用者,AGENTS.md 记录工程约束与开发进度,
仓库整体的开发需求与进度管理见根目录 `agent.md`。
```
HW-Debug-Tools/
├── DLPilot/
│ ├── src/ # 源码 + 打包/发布脚本 + 图标源
│ ├── assets/ # 生成的测试图案 PNG(gen_*.py 产物)
│ ├── release/ # 打包 exe + 运行配置(入库);发布 zip(不入库)
│ ├── README.md # 使用说明
│ └── AGENTS.md # 工程档案
├── EDID-viewer/
│ ├── src/ # 界面/解析/WMI 读取 + 打包/发布脚本
│ ├── assets/ # 图标与生成脚本
│ ├── release/ # EDID-Viewer.exe + README(入库);发布 zip(不入库)
│ ├── README.md # 使用说明
│ └── AGENTS.md # 工程档案
├── SerialMate/
│ ├── src/ # 协议/串口线程/曲线控件/GUI + 自检 + 打包/发布脚本
│ ├── release/ # SerialMate.exe + README(入库);发布 zip(不入库)
│ ├── README.md # 使用说明
│ └── AGENTS.md # 工程档案
├── I2C-Inject/ # PC 端 GUI + RP2040 固件 + 打包 exe + 图标 + README
├── LICENSE
└── README.md
```
## 发布流程(DLPilot / EDID-Viewer / SerialMate 同一套流水线)
```powershell
cd DLPilot/src # 或 EDID-viewer/src、SerialMate/src
python build_exe.py # 打包 → ../release/<工具>.exe
python package_release.py <版本> # → ../release/<工具>-v<版本>-win64.zip
python publish_gitea.py <工具>-v<版本>-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN)
```
每个工具独立打标签发布(`DLPilot-v*` / `EDID-Viewer-v*` / `SerialMate-v*`),Release 列表按时间倒序混排于同一页面。
## 备注
- 源码与打包产物(exe、pattern_slots.json)均入库;发布 zip 不入库,只上传 Gitea Release
- 两件工具的寄存器语义(0x606E 位交换 / 0x606D 整组顺序)均经 demo 板逻辑分析仪
双色交叉实测,过程数据与规程存于上游主仓库 `doc/architecture/`
- 更新:2026-09-28
+227
View File
@@ -0,0 +1,227 @@
# AGENTS.md — SerialMate 工程档案(开发约束与进度)
> 人类文档 [README.md](README.md) 回答"这是什么、怎么用";本文记录"代码怎么组织、
> 怎么构建发布、改动时哪些约束不能破坏",兼作本工具的进度记录。
> 修改本目录代码前先读完本文。仓库整体的开发需求与进度管理见根目录 `agent.md`。
## 1. 项目定位
仓库 `HW-Debug-Tools` 是 LT8619C→DLPC3421 投影转接板的调试上位机工具集。
SerialMate 是其中的**通用串口/RS485 调试助手**:收发 + 协议解析 + 曲线。
默认帧格式与 DLPilot 光机 RS485 协议完全一致(`D5 01 LEN <payload> CRC`),
可直接调试板载 GD32E230 网关;帧格式(包头/板ID/CRC 算法与覆盖范围)可定制,
适配任意"包头+板ID+LEN(=payload长度)+payload+CRC"五段式协议。
协议语义权威同 DLPilot:上游固件仓 `protocols.md`,本仓不维护副本。
## 2. 目录契约
```
SerialMate/
├── src/ 源代码区 —— 只有这里可以手改
├── release/ 产物区 —— SerialMate.exe + README.md(入库),
│ *-win64.zip(gitignore,只上传 Release)
├── assets/ 图标等生成资产(暂无;build_exe.py 已预留 assets/serial-mate.ico)
├── README.md 人类文档
└── AGENTS.md 本文
```
### 文件角色速查
| 文件 | 角色 |
|---|---|
| `src/main.py` | Fluent GUI 入口(含 `--seconds N` 冒烟参数),argparse 仅此一处 |
| `src/protocol.py` | 协议模块(纯标准库):FrameLayout 帧构成令牌、ProtoConfig 组帧/校验、FrameParser 增量解析、CRC 算法表、extract_value 数值提取 |
| `src/link.py` | 串口工作线程 SerialLink(pyserial 只准在这里 import),支持 `loop://` 虚拟回环 |
| `src/plot.py` | 滚动曲线控件(纯 QPainter,刻意不引 matplotlib/pyqtgraph) |
| `src/config.py` | BASE_DIR 定位与 serial_mate.json / board_profiles.json 读写(缺键补默认,读容错) |
| `src/theme.py` | 主题三件套同步(Fluent setTheme + QPalette/QSS + DWM 标题栏),与 EDID-Viewer 同源 |
| `src/selftest.py` | 协议自检(无依赖、无硬件,改 protocol.py 后必跑) |
| `src/selftest_loop.py` | 链路自检(loop:// 回环验证 组帧→发送→接收→解析→提取,缺 pyserial 跳过) |
| `src/selftest_gui.py` | GUI 功能自检(offscreen 驱动事件处理函数:预览/回显/提取/配置往返,改 main.py 后建议重跑) |
| `src/build_exe.py` | PyInstaller onefile 打包 → `release/SerialMate.exe` |
| `src/package_release.py` | exe+README → `release/SerialMate-v<版本>-win64.zip` |
| `src/publish_gitea.py` | 建/复用 Gitea Release 并上传 zip(`GITEA_TOKEN`,需 `--insecure`) |
## 3. 代码不变量(改代码必须维持)
1. **依赖分层**:protocol.py / config.py 纯标准库;pyserial 只准在 link.py
import(main.py 经 `SerialLink` 间接使用);plot.py 只依赖 PySide6;
GUI 硬依赖 PySide6-Fluent-Widgets + pyserial(串口工具缺 pyserial 即无意义,
与 DLPilot 的"缺失降级"策略不同,只保留 try-import 提示)。
1a. **卡片布局约定**:`_card(side=True)` 把短副标题放标题右侧省一行(串口/
协议/发送卡);右列是纵向 QSplitter(接收/发送高度用户可拖拽,拉伸余量
默认给接收、发送卡保持自然高度防空腔);发送卡末尾 `lay.addStretch(1)`
保证内容贴顶(卡片被拖高后富余高度收在卡底,勿删——否则拉伸被各行均分,
标题上方出现空隙);发送卡行序:板型管理行(下拉+新建+删除+配置+重载) /
预设行(下拉+填入+直接发送) / 模式行(下拉+存为板型+存入预设) / 输入行 /
周期行;帧日志 payload 高亮走 `_append_rx_frame`(html.escape 后拼 span
背景,toPlainText 内容不变,selftest 的日志断言因此不受影响);预设下拉
显示"名称 · 命令",存入命令走 `_add_command_named`(弹窗核 `_add_command`
包装,测试用核函数),预设直发走 `_send_preset_direct`(经输入框复用
组帧/历史链路)。
1b. **窗口最小高度**:首帧布局后 `_apply_minimum_size` 按内容实际最小高度
抬高窗口下限(高度拉过矮时布局约束无法满足,曲线卡绘图区会被压塌并与
通道行互相叠压——用户实测);勿删, 调整曲线卡行数/高度后该值自动跟随。
2. **回调线程边界**:SerialLink 的 on_status/on_frame/on_raw/on_tx 全在后台线程
触发,一律 `events.put(...)` 投递,主线程 30ms QTimer 轮询 `_drain_events`
分发(与 DLPilot `_poll_dlpc` 同模式),禁止回调里直接碰 GUI。
3. **半双工节奏**:link 工作线程每个循环周期最多出队写一帧(20ms 间隔),
RS485 总线多帧连发留应答间隙,勿改成批量 drain 一次写。
4. **帧构成语义**:帧由 `FrameLayout` 令牌序列定义(`HDR ID LEN PAYLOAD CRC`
令牌 + 十六进制字面量,收发共用布局、仅包头值分方向);约束:LEN/PAYLOAD/CRC
恰好各一次、CRC 必须最后、LEN 前至少一个 HDR 或字面量(同步定界)、ID 最多一次。
LEN 口径(`LEN_SEMS`):payload 长度 / 到帧尾(payload+CRC) / 整帧;
CRC 算法/覆盖范围见 protocol.CRC_ALGOS/CRC_SCOPES,覆盖按**帧内位置**定义
(with_header=第1字节起 / no_header=跳过第1字节 / payload_only=仅载荷),
任意布局通用;2 字节 CRC 线上字节序 MODBUS 低前、其余高前,按算法约定单序
校验(改动需同步 selftest 向量)。
5. **解析重同步策略**:前缀令牌逐位对齐(HDR/字面量须匹配、ID 通配),LEN 值经
口径换算为 payload 长度,负值视为伪帧头逐出;CRC 失配丢 1 字节重同步——
通用工具不预设合法 LEN 区间(与 DLPilot 的 LEN 范围预判不同)。
6. **ID 位不校验只透出**:`Frame.code` 原样上抛(布局无 ID 时为 None,GUI 日志
与 selftest 均需兼容 None),TYPE_NAMES 仅用于显示译码(DLPilot 语义)。
7. **BASE_DIR 语义**(config.py):frozen 时 =exe 所在目录,源码运行时 =src/;
设置平铺存 `serial_mate.json`,不建子目录。`tx.periodic` 不随配置
自动启动(防误连发),其余配置启动恢复。
8. **曲线数据流**:每帧对每个启用通道调 `extract_value`(失败静默跳过),
`PlotWidget.add` 内部 deque(maxlen=3000) 滚动丢弃;数值格式统一
`fmt_val`(4 位有效数字)。
9. **主题**:theme.py 三件套同步(见 EDID-Viewer 教训:只 setTheme 会黑块);
plot 曲线颜色固定 4 色,文字/网格色取自 QPalette 自动跟随明暗。
9a. **曲线时间窗**:PlotWidget 持有 `_span`(窗宽秒, None=跟随全部) 与
`_offset`(右端距最新秒数, 0=实时);横轴域换算在 `visible_domain`(纯计算,
selftest 有断言)。**窗口不向数据收敛**(用户实测反馈):选 5min 而数据只有
23s 时显示完整 5min 轴、曲线贴右端随采集填满(示波器式),右下角标注
"已采集 Xs / 窗口 Ns";滞后超出全部历史 = 空窗保宽。弹窗内 时间窗下拉+
滑条 只改这两个状态;滑条 0..1000 线性映射 (1000-v)/1000×maxpan,实时
跟随=拉到最右(1000);poll 定时器 `_sync_time_slider` 回写位置(用户拖动中
跳过);嵌回卡片时强制重置为跟随全部(卡内无滑条, 防困在滞后窗里)。纵轴
数值刻度画在每条横向参考线上。
悬停读值:mouseTracking 开启, mouseMove 只记录 `_hover_pos` 并请求重绘;
读值内容由 paintEvent **每帧**经 `_hover_lines_at(_hover_pos)` 重算(光标
时间 T 经 `_plot_geometry` 换算, `_nearest` 二分取 ±10px 容差内最近采样),
画在顶部居中反色小框(`_hover_box`)——鼠标停住不动时数值也随实时数据持续
刷新(勿改回 mouseMove 里一次性算好/或 QToolTip, 那样不动鼠标就不更新——
用户实测反馈)。`_nearest`/`_hover_lines_at` 有单测。绘图区内边距统一用
`M_LEFT/TOP/RIGHT/BOTTOM` 类常量(绘制与鼠标换算共用, 勿在 paint 里另写)。
9b. **暂停(两级语义)**:主界面"暂停"= `_paused`, add() 丢弃暂停期间的数据;
弹窗"暂停渲染"= `_frozen`, 数据照常入库但跳过重绘——冻结是**绝对状态**:
PlotWidget 覆写 `update()` 直接吞掉一切重绘请求(任何代码路径都无法在
冻结期打破), 只有 `set_frozen(False)` 恢复并一次性渲染积累;冻结时画面
中央显示"已冻结渲染(采集继续)"角标让状态可见。两者互不干扰, 均有
selftest 断言。
9c. **弹窗曲线交互**:自动Y(勾选按钮, 与主界面 chk_auto_y 双向同步, 关闭时
以 `auto_yrange()` 初始化手动量程);图上左键拖动 = 平移手动 Y 量程
(`on_yrange_changed` 回调同步主界面 Ymin/Ymax 输入框, 阻断信号防回环);
滚轮 = 缩放时间窗 2~900s(滞后按比例缩放, 经 `on_span_changed` 回调让
`_sync_time_slider` 把时间窗下拉同步为匹配档位/自定义(-1));"实时"按钮
= offset 归零跳回最新。自动 Y 量程抽取为 `auto_yrange()`(纯计算, 有单测)。
11a. **曲线弹出窗口**:绘图区在曲线卡留有固定槽位布局 `_plot_slot`,弹出 =
`setParent(None)` 后装入顶层窗口(通道/暂停/Y 轴控件留在主界面控制同一
PlotWidget 实例);关掉弹出窗口(eventFilter 捕 Close)或主窗 closeEvent
都会走 `_dock_plot()` 嵌回 —— `_dock_plot` 必须先摘 `self._plot_win` 引用
再 `win.close()`(防 Close 递归),且 `setParent(None)` 后必须补 `show()`
(重挂布局不会自动恢复可见)。
10. **多实例**:有意不加单实例锁(双口双开的合法场景),串口冲突靠
pyserial 报错提示("串口被占用"文案在 link.py,勿删)。
11. **板型档案**:`board_profiles.json`(BASE_DIR 平铺,用户本机数据不入库,
首次运行自动生成默认"DLPilot光机")。档案 = name + proto(7 个帧协议字段,
**不含 parse** —— 解析开关是查看偏好) + commands[[名,命令]..];切换板型
应用协议参数并装载命令表,"存入"命令以命令文本为名去重;启动恢复选中
板型时**只装载命令表不重复应用协议参数**(协议以上次会话 store 为准);
GUI 增删后经 config.save_profiles 落盘,"配置/重载"支持手改 JSON。
## 4. 构建与发布(全部在 src/ 下执行)
```powershell
python -m compileall -q . # 全模块可编译
python selftest.py # 协议回归(47 项, 改 protocol.py 后必跑)
python selftest_loop.py # 链路回环(需 pyserial, 改 link.py 后必跑)
python selftest_gui.py # GUI 功能回归(offscreen, 改 main.py 后建议重跑)
python main.py --seconds 5 # GUI 冒烟(源码); offscreen: set QT_QPA_PLATFORM=offscreen
python build_exe.py # → ../release/SerialMate.exe(缺 PyInstaller 自动装)
python package_release.py <版本> # → ../release/SerialMate-v<版本>-win64.zip
python publish_gitea.py ../release/SerialMate-v<版本>-win64.zip --replace --insecure
```
发布约定与 Gitea 实例怪癖(TLS 抽风加 `--insecure`、assets 两种形状兼容、
token 读 `GITEA_TOKEN` 用户级环境变量)同 DLPilot,详见 `DLPilot/AGENTS.md` §5;
tag/release 命名 `SerialMate-vX.Y.Z`,zip 命名
`SerialMate-vX.Y.Z-win64.zip`(publish_gitea.py 从文件名推 tag)。
## 5. 已知坑(本工具踩过/继承的)
- `qfluentwidgets.CaptionLabel` 无 `setDim` 方法(本机版本),灰字用
`CaptionLabel` 本身即可;`ComboBox` 不要用 `currentData`(qfluentwidgets
自绘下拉 userData 支持不稳),一律 currentIndex + 平行元组映射。
- **qfluentwidgets 控件默认水平 Expanding**:紧凑行不设 stretch 项时空白会被
CheckBox/Label 等瓜分出大空隙 —— 一律走 `Window._row()`(行尾默认补
stretch;Qt 规则:有 stretch 因子时额外空间只按因子分配),需要自管拉伸的
行传 `trailing=False`。
- **qfluentwidgets SpinBox/DoubleSpinBox 设固定宽后内部行编辑器会塌缩**
(minimumSizeHint 142/193 只是参考,强设小于它的宽度时行编辑器只剩几像素,
数值被裁成残字——整数 0 在 96px 时渲染成 "]"):整数 SpinBox ≥110px、
DoubleSpinBox ≥130px 才能完整显示数值;曲线卡偏移/序号/倍率/Y 量程框的
宽度有 selftest 断言把守, 调布局时勿低于阈值。
- 组帧/解析回路自测时必须让 `tx_hdr == rx_hdr`(收发方向包头本就允许不同,
D5 组的帧 B5 解析器看不见——selftest 的 fuzz/回路用例已按此约定)。
- offscreen 冒烟报 `QFontDatabase: Cannot find font directory` 属 Qt offscreen
平台噪音,桌面运行无此问题,勿当 bug 修。
- onefile 运行时定位 exe 用 `sys.executable`(`__file__` 在 `_MEIPASS`),
config.py 已按此实现。
## 6. 验证清单(改完代码后)
1. `python -m compileall -q .` —— 全模块可编译;
2. `python selftest.py` —— 协议回归(改 protocol.py 后必跑);
3. `python selftest_loop.py` —— 链路回环(改 link.py 后必跑);
4. `python main.py --seconds 5` —— GUI 冒烟自动开关;
5. `python build_exe.py` → 双击 release/SerialMate.exe 确认界面渲染;
6. (有硬件时)USB-RS485 接光机发 M1/M201/M731 验证真机收发与曲线。
## 7. 进度记录
### v0.1.0(2026-09-27,首版)
已完成:
- 协议模块:**帧构成令牌序列可定制**(`HDR ID LEN PAYLOAD CRC` 令牌 + 十六进制
字面量同步字节,收发共用布局,支持字段换序/无板ID/多字节同步头;LEN 口径三种:
payload 长度 / 到帧尾 / 整帧)、5 种 CRC 算法 × 3 种覆盖范围(按帧内位置定义,
任意布局通用)、增量解析(前缀逐位对齐 + CRC 失败逐字节重同步)、
二进制/文本/正则三种曲线数值提取;自测 64 项全 PASS(含 DLPilot 实测帧向量、
CRC 标准校验值 check("123456789")、布局换序/字面量/LEN 口径向量与布局模糊往返)。
- 串口链路:参数热切换、发送队列(半双工友好 + 未连接丢弃)、解析/透传双模式
热切、`loop://` 虚拟回环自检 13 项全 PASS(组帧→发送→接收→解析→曲线提取)。
- GUI(PySide6-Fluent-Widgets):串口/协议/曲线/接收/发送五卡,帧构成与 LEN 口径
可视化编辑(非法输入即时提示),payload 字符串/HEX 切换(帧日志的 payload
段垫半透明底色高亮——主题无关 rgba, 明暗切换不残留, 经 appendHtml 实现)、
原始帧 hex、时间戳、日志保存;发送:自动包装(`/` 分隔多条)、HEX 直发、
周期发送、组帧实时预览、历史回溯(↑↓)、回车发送;主题跟随系统。
- 曲线(plot.py + 弹窗,2026-09-27 实机反馈多轮迭代):4 通道三种提取方式 +
倍率,自动/手动 Y 量程;绘图区可**弹出独立窗口**(嵌回自动重置视图);
纵轴每条参考线标数值刻度;时间窗(跟随/10s~15min, 示波器式不向数据收敛,
右下角"已采集/窗口"标注)+ 横向滑条回看历史;悬停读值框固定顶部、鼠标
不动也随数据持续刷新;**暂停渲染**(冻结显示继续收数, 与主界面"暂停"
两级语义, 冻结有中央角标);自动Y 开关、图上拖动自定义 Y 轴(同步主界面
输入框)、滚轮缩放时间窗(2~900s)、实时按钮;窗口最小高度按内容自适应
(防高度拉小时通道行叠压绘图区)。
- 板型档案:发送卡"板型"下拉一键切换协议参数 + 命令预设;**新建板型**(协议
沿用当前、命令预设空白,防遗留其他板子的命令)/存为板型(同名覆盖)/
**删除板型**(确认弹窗,至少保留一个,删除后切第一个板型且协议参数不动)/
存入预设命令(弹窗自定义显示名称)/配置=内嵌表格编辑器(**CommandTable**:
第 0 列 ≡ 手柄按住上下拖动整行排序——内部 QDrag,move_row 整行搬移;
网格线/隔行底色按 palette 明暗自适配,空行清晰可见;+加行 −删行;空命令与
重复命令保存时忽略/去重,写回 board_profiles.json)/重载;预设下拉
"名称 · 命令"同显。
- 发布流水线:build_exe/package_release/publish_gitea 与全仓一致;exe 已重建
入库,zip/Release 未发布(待用户验收)。
待办 / 计划:
- 【无图标】assets/serial-mate.ico 未生成(build_exe.py 已预留路径,
参照 DLPilot logos/make_ico.py 流程补)。
- 【真机已过】用户已实机联调(COM15 真机收发/曲线提取/多板型档案实际使用,
2026-09-27 多轮界面反馈即来自真机数据);温度曲线量纲(倍率取值)待用户
按传感器手册确认。
- 【计划】发送区多条 payload 的"批处理脚本"化(延时/循环/条件等待应答)。
- 【计划】曲线数据导出 CSV;接收日志按日期自动分文件。
- 【考虑】CRC 解析侧双字节序兼容开关(当前按算法约定单序)。
+130
View File
@@ -0,0 +1,130 @@
# SerialMate — RS485/串口调试助手
> LT8619C 调试工具集配套工具:串口收发 + 协议包解析 + 曲线绘制。
> 帧格式(包头/板ID/LEN/CRC)全可定制,默认与 [DLPilot](../DLPilot) 光机
> RS485 协议一致,可用来直接调试板载 GD32E230 网关或任意同构协议设备。
## 功能
- **串口收发**:端口自动枚举、波特率 1200~921600、数据位/校验/停止位可选,
参数热切换;半双工 RS485 友好(每周期最多写一帧,留应答间隙)。
- **协议解析**(可关,关=原始透传):
增量字节流定界 + CRC 校验,失败逐字节重同步,噪声自动丢弃;
接收帧 ID 位按 DLPilot 语义译码显示(0xF0=成功、0xFD=参数越界…)。
- **帧构成定制**(协议卡,发送/接收共用同一布局,仅包头值分方向):
帧构成用**令牌序列**定义,默认 `HDR ID LEN PAYLOAD CRC`(即 DLPilot 五段式):
| 令牌 | 含义 |
|---|---|
| `HDR` | 包头(值取 TX包头 / RX包头 字段) |
| `ID` | 板ID(RX 侧多为响应 TYPE 码,不校验仅透出显示) |
| `LEN` | 长度字节,口径可选:**payload 长度(默认)** / 到帧尾(payload+CRC) / 整帧 |
| `PAYLOAD` | 协议载荷(发送框内容,别名 DATA) |
| `CRC` | 校验(算法/覆盖范围见下表,必须在末尾) |
| 十六进制字面量 | 固定字节,如 `AA 55` 双字节同步头 |
示例:`AA 55 LEN PAYLOAD CRC`(AA55 同步头、无板ID)·
`HDR LEN PAYLOAD ID CRC`(板ID 在载荷后)· `HDR LEN ID PAYLOAD CRC`(板ID 在长度后)。
约束:LEN/PAYLOAD/CRC 恰好各一次,LEN 前至少一个 HDR 或字面量作同步头。
| 项 | 说明 | 默认 |
|---|---|---|
| TX 包头 | HDR 令牌在命令帧的值 | `D5` |
| RX 包头 | HDR 令牌在响应帧的值 | `B5` |
| 板ID | ID 令牌的值(TX 用;RX 侧仅透出) | `01` |
| LEN 口径 | payload 长度 / 到帧尾(payload+CRC) / 整帧 | payload 长度 |
| CRC 算法 | SUM8 累加和 / CRC8 / CRC16 MODBUS / CRC16 XMODEM / CRC16 CCITT-FALSE | SUM8 |
| CRC 覆盖 | 含包头(整帧) / 不含包头(跳过第1字节) / 仅 payload | 不含包头 |
- **接收显示**:payload 一键切换 **字符串 / HEX**;可选附原始帧 hex;
时间戳;日志保存为 txt。
- **曲线**:4 通道,每帧有效数据按通道提取一个数值,支持三种提取方式
(二进制定长 u8~f32 大小端 / 文本第 N 个数字 / 自定义正则)+ 倍率,
y 轴自动或手动量程,暂停/清空;绘图区可**弹成独立窗口**("弹出"按钮,
关掉窗口即嵌回),通道配置与暂停等控件留在主界面照常控制。弹出窗口内:
纵轴每条参考线标注数值刻度;**时间窗**可选宽度(跟随/10s/30s/1min/5min/
15min),配合横向滑条回看历史总体趋势或缩小窗口细看局部走势,拉到最右
恢复实时跟随(冻结查看请配合"暂停");窗口宽度不向数据收敛——数据不足时
显示完整的所选时间轴、曲线贴右端随采集逐渐填满(示波器式),右下角标注
"已采集 Xs / 窗口 Ns";**鼠标悬停**在曲线上,绘图区顶部的小读数框持续显示
光标处时间点 T 与各通道在该时刻的 Y 轴数值(±10px 容差取最近采样);
弹出窗口工具栏:**暂停渲染**(只冻结画面方便查看,采集继续入库,点"继续
渲染"后把积累的数据一次性画出来;画面中央会显示"已冻结渲染"角标;注意
与主界面"暂停"的区别:那个会丢弃暂停期间的数据)、**自动Y**(开/关自动
纵轴,关闭后可在图上按住左键上下拖动自定义 Y 轴范围)、**实时**(取消
滞后跳回最新)、**滚轮**在图上缩放时间窗(2s~15min)。
实例:回包 payload `01 AE 75 6D`(前 2 字节固定、第 3~4 字节是 16 位大端
采样值 0x7444~0x7696)→ 勾选通道1,方式=二进制,偏移=2,格式=u16 大端,
倍率按传感器量纲填(如 0.01);最新值显示在通道行尾并进入曲线。
注意偏移是 payload 内的字节序号(0=第 1 字节),选错位宽/偏移会把多个字段
拼成一个数(如 u32@偏移0 会把整包读成 28,210,029);数值显示为千分位普通
小数,≥10 亿才用科学计数。
- **板型档案**:不同板子的"协议参数 + 命令预设表"存为命名档案,保存在 exe
同目录的 `board_profiles.json`。发送卡选中板型即一键套用其协议参数
(包头/板ID/帧构成/LEN口径/CRC)并切换命令预设;**新建板型**按当前协议参数
建档、命令预设从空白开始;**存为板型**把当前配置存成档案(同名覆盖);
**删除板型**带确认(至少保留一个);"存入预设"把当前输入的命令加入当前
板型(可自定义显示名称);**配置**打开表格编辑器(名称/命令两列,+加行
−删行,保存写回档案);预设下拉以"名称 · 命令"同显。
- **发送**:
- **自动包装**:输入协议载荷(如 `M731S0T1000`)自动组帧加包头/板ID/LEN/CRC;
多条用 `/` 分隔依次发送(如 `M1/M731S0T1000`);
- **HEX 直发**:输入完整帧 hex 原样发出;
- 周期发送(100ms~60s)、输入框实时组帧预览、预设命令一键填入、
命令历史 ↑↓ 回溯、回车即发送。
## 运行
- 直接运行 `release/SerialMate.exe`(免安装,Windows 10/11 x64);
- 或源码运行:`py -3 -m pip install PySide6 PySide6-Fluent-Widgets pyserial`,
然后 `cd src && python main.py`。
## 界面速览
```
┌ SerialMate ─────────────────────────────────────────────────┐
│ 串口卡 协议卡 │ 接收卡(payload 字符串/HEX) │
│ COMx 115200 8N1 D5/B5/01 │ ← 12:00:00 B=0xF0 成功 LEN=2 ok │
│ 曲线卡(4通道) │ ┌───────── 滚动曲线 ─────────┐ │
│ ☑ DMD温度 文本数字 第1个 ×0.1 │ └────────────────────────────┘ │
│ │ 发送卡: [M1/M731S0T1000] 发送 │
└────────────────────────────────┴──────────────────────────────────┘
```
## 典型用法:调光机 RS485
1. 串口卡选 USB-RS485 适配器,115200 8N1,连接;
2. 发送框输入 `M1` 回车——自动包装为 `D5 01 02 4D 31 81` 发出,
接收区回 `ok`(协议卡默认即 DLPilot 协议);
3. 查温度:发 `M201`,回包 `okTL:246` 译码显示"成功",曲线卡勾选通道
设为"文本数字·第1个·×0.1",周期发送勾上,即可看温度曲线;
4. 换任何自定义协议:改协议卡的**帧构成**(字段顺序/有无板ID/字面量同步头)、
包头/板ID/LEN 口径/CRC 即可,收发解析同步生效;调好后点"存为板型",
以后一键切换。
## 目录
```
SerialMate/
├── src/ 源代码(protocol 协议 / link 串口线程 / main GUI / selftest 自检)
├── assets/ serial-mate.ico 图标(assets/make_icon.py 生成)
├── release/ SerialMate.exe + README(发布文件夹,zip 只上传 Release)
└── README.md 本文
```
运行时用户数据与 exe 同目录平铺:`serial_mate.json`(串口/协议/通道记忆)、
`board_profiles.json`(板型档案,首次运行自动生成默认"DLPilot光机")。
## 开发 / 发布
```powershell
cd src
python selftest.py # 协议回归(无依赖, 改 protocol.py 后必跑)
python selftest_loop.py # 链路回环自检(loop:// 虚拟口, 需 pyserial)
python main.py # 源码运行
python build_exe.py # → ../release/SerialMate.exe
python package_release.py <版本> # → release/SerialMate-v<版本>-win64.zip
python publish_gitea.py ../release/SerialMate-v<版本>-win64.zip --replace --insecure
```
工程约束与进度见 [AGENTS.md](AGENTS.md);协议语义唯一权威是上游固件仓
`protocols.md`(本仓 DLPilot 目录有引用说明)。
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
SerialMate 图标栅格化(家族风格, 与 DLPilot/EDID-Viewer/FlashTool 同版式):
黑底 512, 绿色圆角矩形, 第一行 Serial(框内黑字), 第二行 Mate(白字)
================================================================
设计参数: 黑底 512, 绿色圆角矩形 x38 y64 w436 h186 rx34 (#00C878, 与
gd32e230f8_firmware_merge_tool 的 FlashTool 同色系), Arial Bold。
Serial 6 字母按框宽自适应字号(上限 146px, 留边距),
Mate 146px 白字(基线对齐同 DLPilot 的 Pilot)。
用法: python make_icon.py # 生成 serial-mate.png / serial-mate.ico
"""
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 fit_font(draw, text, max_w, start):
"""从 start 字号往下找能装入 max_w 宽的字号(步进 2)"""
size = start
while size > 8:
f = ImageFont.truetype(FONT, size)
box = draw.textbbox((0, 0), text, font=f)
if box[2] - box[0] <= max_w:
return f
size -= 2
return ImageFont.truetype(FONT, 8)
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=(0, 200, 120))
if not os.path.exists(FONT):
raise SystemExit(f'未找到字体 {FONT}, 无法栅格化')
# Serial: 黑字居中于橙色框, 按框宽自适应(留 2×18px 边距)
f1 = fit_font(d, 'Serial', 400 * s, round(146 * s))
d.text((size / 2, 64 * s + 186 * s / 2), 'Serial', font=f1,
fill=(0, 0, 0), anchor='mm')
# Mate: 白字, 与家族一致 146px(无下伸字母, 中心 = 基线421 - cap/2 ~= 368)
f2 = ImageFont.truetype(FONT, max(8, round(146 * s)))
d.text((size / 2, 368 * s), 'Mate', font=f2, fill=(255, 255, 255),
anchor='mm')
return img
if __name__ == '__main__':
img = render(512)
png = os.path.join(BASE, 'serial-mate.png')
ico = os.path.join(BASE, 'serial-mate.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}')
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Some files were not shown because too many files have changed in this diff Show More