Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b58cdc42b6 | ||
|
|
21c1b7bcc1 | ||
|
|
144730fb3a | ||
|
|
ca898be299 | ||
|
|
511e7dfa44 | ||
|
|
6d1e2c6f8d | ||
|
|
a0d64c322a | ||
|
|
a666ed7c43 | ||
|
|
1f68665860 | ||
|
|
668a7e1ca5 | ||
|
|
bf9d70722c | ||
|
|
5d06e69a41 | ||
|
|
a3430d15e0 | ||
|
|
419c902a38 | ||
|
|
b1bb320b50 | ||
|
|
2d1d8f83b1 | ||
|
|
d991d57447 | ||
|
|
43d78de045 | ||
|
|
739fdeedf3 | ||
|
|
b7eda91ca7 | ||
|
|
763d80534c | ||
|
|
31fcd93607 | ||
|
|
df6a5f4343 | ||
|
|
32d0d26c5a | ||
|
|
afb6513e56 | ||
|
|
584fd32ebe | ||
|
|
b12bc584b8 | ||
|
|
13dab778c6 | ||
|
|
607767ce19 | ||
|
|
210e901ede | ||
|
|
9a70efc6de | ||
|
|
19afeed294 | ||
|
|
cca7cbc962 | ||
|
|
aff31656ed | ||
|
|
bb8efa7392 | ||
|
|
7051aa1774 | ||
|
|
222d452f63 | ||
|
|
0a7b1f576a | ||
|
|
4520ca970d | ||
|
|
cdef23b80a | ||
|
|
0ca7ea8675 | ||
|
|
a39a4a665a | ||
|
|
7ab8264752 | ||
|
|
1f7e697558 | ||
|
|
65c85c2844 | ||
|
|
c38f12b230 |
@@ -0,0 +1,17 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# PyInstaller 工作目录(打包产物 exe 已直接入库, 构建中间物不入库)
|
||||
build/
|
||||
dist/
|
||||
*.spec
|
||||
|
||||
# 发布 zip: 可由 src/package_release.py 重新生成, 只上传 Gitea Release
|
||||
DLPilot/release/*.zip
|
||||
|
||||
# 源码模式运行时的配置写入(config.BASE_DIR=src/), 与 release/ 下入库的预置栏位区分
|
||||
DLPilot/src/pattern_slots.json
|
||||
|
||||
# ZCode 客户端本地会话数据(计划留档等), 非项目配置
|
||||
.zcode/
|
||||
@@ -0,0 +1,179 @@
|
||||
# AGENTS.md — DLPilot 工程档案(供 AI/Agent 使用)
|
||||
|
||||
> **本文档面向 AI/Agent**,是人类文档 [README.md](README.md) 的姊妹篇,两者不重叠:
|
||||
> README 回答"这是什么、怎么用";本文回答"代码怎么组织、怎么构建发布、改动时哪些约束不能破坏"。
|
||||
> 任何 Agent 在修改本目录代码前应先读完本文。
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
仓库 `LT8619C_Debug_Tools` 是 LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板的**调试上位机工具集**,
|
||||
上游主仓库(固件)为 `hulk/LT8619C_DLPC3421_HDMI`(同 Gitea 实例 `gitea.hulk.wang`),
|
||||
上游固件本机路径 `E:\Hulk-Coding\LT8619C_DLPC3421_HDMI`;工具仓库路径 `E:\Hulk-Coding\LT8619C_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。
|
||||
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 hdmi_gui.py --seconds 5` —— GUI 冒烟(自动开关);
|
||||
4. `python hdmi_gui.py --test-pattern Checker --area 800x600 --seconds 3` —— 投图链路;
|
||||
5. `python hdmi_gui.py --dump` —— EDID 解算不抛异常;
|
||||
6. `python hdmi_gui.py --dlpc-cmd M999`(不带 --port)—— 应明确报错而非崩溃(无硬件时的优雅降级);
|
||||
7. `python build_exe.py` → 双击 release/DLPilot.exe 确认图标/双栏布局/投图/光机区渲染;
|
||||
8. `python package_release.py <版本>` —— 不报"引用图片缺失"即栏位完整性 OK。
|
||||
|
||||
## 8. v0.4.0 交接(2026-09-24)
|
||||
|
||||
- 入口已切换 `app_fluent.py`;历史 `app_gui.py` 保留,不改其历史版本号。
|
||||
- 本次验证:源码编译通过、RS485 自测 ALL PASS、Qt 离屏浅色渲染、正式 EXE 定时退出码 0。
|
||||
原生桌面自动化不可用,未完成成品 Light 截图;未将硬件功能标记为本次实机验证通过。
|
||||
- 发布须将本次源码、文档、EXE 纳入同一个提交,确认远端标签指向该提交;zip 不入库。
|
||||
- `release/pattern_slots.json` 的本机串口记忆改动、外部 `EDID-viewer/` 不属于本次 UI 提交。
|
||||
发布使用仓库预置配置,临时打包后恢复本机文件,勿将运行态配置悄悄提交。
|
||||
@@ -0,0 +1,256 @@
|
||||
# DLPilot — HDMI 源端调试 + 光机控制工具
|
||||
|
||||
> LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套的 PC 端工具:
|
||||
> HDMI **源端**(EDID 查看、分辨率切换、纯色/测试图案投屏)+ **光机控制**
|
||||
> (RS485 直连板上 GD32 网关,开关光机/设光强/查温度电流)。
|
||||
>
|
||||
> - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/LT8619C_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/LT8619C_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`,两份安装互不干扰。
|
||||
@@ -0,0 +1,168 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="343.44351mm"
|
||||
height="287.24921mm"
|
||||
viewBox="0 0 343.44351 287.24921"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-276.89459,-7.1259639)">
|
||||
<rect
|
||||
style="fill:none;stroke:#000000;stroke-width:0.264583"
|
||||
id="rect2"
|
||||
width="107.61474"
|
||||
height="17.995777"
|
||||
x="310.24719"
|
||||
y="19.795353" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:7.05556px;text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="329.95258"
|
||||
y="30.952734"
|
||||
id="text2"><tspan
|
||||
id="tspan2"
|
||||
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="329.95258"
|
||||
y="30.952734">显示器选择区域</tspan></text>
|
||||
<rect
|
||||
style="fill:none;stroke:#000000;stroke-width:0.264583"
|
||||
id="rect2-8"
|
||||
width="107.61474"
|
||||
height="17.995777"
|
||||
x="476.34061"
|
||||
y="19.478064" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:7.05556px;text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="496.04599"
|
||||
y="30.635443"
|
||||
id="text2-8"><tspan
|
||||
id="tspan2-2"
|
||||
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="496.04599"
|
||||
y="30.635443">串口选择区域</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:7.05556px;text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="498.63492"
|
||||
y="52.226608"
|
||||
id="text2-8-7"><tspan
|
||||
id="tspan2-2-6"
|
||||
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="498.63492"
|
||||
y="52.226608">串口参数选择显示</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-size:7.05556px;text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="341.34952"
|
||||
y="75.572121"
|
||||
id="text2-8-1"><tspan
|
||||
id="tspan2-2-7"
|
||||
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="341.34952"
|
||||
y="75.572121">支持的模式</tspan></text>
|
||||
<rect
|
||||
style="fill:none;stroke:#000000;stroke-width:0.264583"
|
||||
id="rect3"
|
||||
width="150.08478"
|
||||
height="73.782684"
|
||||
x="296.5704"
|
||||
y="45.709274" />
|
||||
<rect
|
||||
style="fill:none;stroke:#000000;stroke-width:0.219727"
|
||||
id="rect4"
|
||||
width="149.76971"
|
||||
height="63.030079"
|
||||
x="297.26779"
|
||||
y="124.14843" />
|
||||
<rect
|
||||
style="fill:none;stroke:#000000;stroke-width:0.324039"
|
||||
id="rect4-21"
|
||||
width="148.22574"
|
||||
height="138.50803"
|
||||
x="462.45538"
|
||||
y="65.445747" />
|
||||
<rect
|
||||
style="fill:none;stroke:#000000;stroke-width:0.178131"
|
||||
id="rect4-2"
|
||||
width="150.89105"
|
||||
height="41.116825"
|
||||
x="297.18097"
|
||||
y="197.28166" />
|
||||
<rect
|
||||
style="fill:none;stroke:#000000;stroke-width:0.183274"
|
||||
id="rect4-2-4"
|
||||
width="150.52599"
|
||||
height="43.631092"
|
||||
x="297.79538"
|
||||
y="244.01398" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="344.43915"
|
||||
y="162.68181"
|
||||
id="text4"><tspan
|
||||
id="tspan4"
|
||||
style="fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="344.43915"
|
||||
y="162.68181">EDID信息</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="309.88724"
|
||||
y="220.62822"
|
||||
id="text6"><tspan
|
||||
id="tspan6"
|
||||
style="stroke-width:0.264583"
|
||||
x="309.88724"
|
||||
y="220.62822">测试的图案、纯色、自定义图案等区域</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="342.27966"
|
||||
y="259.85901"
|
||||
id="text7"><tspan
|
||||
id="tspan7"
|
||||
style="stroke-width:0.264583"
|
||||
x="342.27966"
|
||||
y="259.85901">EDID原始数据</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="511.7999"
|
||||
y="118.41221"
|
||||
id="text8"><tspan
|
||||
id="tspan8"
|
||||
style="stroke-width:0.264583"
|
||||
x="511.7999"
|
||||
y="118.41221">串口数据区域</tspan></text>
|
||||
<rect
|
||||
style="fill:none;stroke:#000000;stroke-width:0.264583"
|
||||
id="rect8"
|
||||
width="145.40587"
|
||||
height="70.903358"
|
||||
x="464.29105"
|
||||
y="213.78983" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:300;font-stretch:normal;font-size:7.05556px;font-family:'Maple Mono Normal';-inkscape-font-specification:'Maple Mono Normal, Light';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="498.483"
|
||||
y="240.06366"
|
||||
id="text9"><tspan
|
||||
id="tspan9"
|
||||
style="fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="498.483"
|
||||
y="240.06366">预设按钮、自定义命令</tspan><tspan
|
||||
style="fill:#000000;stroke:none;stroke-width:0.264583"
|
||||
x="498.483"
|
||||
y="248.96394"
|
||||
id="tspan10">亮度、时间等快捷滑条区域</tspan></text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,256 @@
|
||||
# DLPilot — HDMI 源端调试 + 光机控制工具
|
||||
|
||||
> LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套的 PC 端工具:
|
||||
> HDMI **源端**(EDID 查看、分辨率切换、纯色/测试图案投屏)+ **光机控制**
|
||||
> (RS485 直连板上 GD32 网关,开关光机/设光强/查温度电流)。
|
||||
>
|
||||
> - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/LT8619C_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/LT8619C_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`,两份安装互不干扰。
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,869 @@
|
||||
#!/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, setTheme,
|
||||
)
|
||||
|
||||
import config
|
||||
import display_win
|
||||
import dlpc_rs485
|
||||
import edid_parse
|
||||
import patterns
|
||||
|
||||
APP_NAME = "DLPilot"
|
||||
APP_VERSION = "0.4.0"
|
||||
REPO_URL = "https://gitea.hulk.wang/hulk/LT8619C_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)
|
||||
title.setStyleSheet("font-size: 24px; font-weight: 700;")
|
||||
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)
|
||||
button.setStyleSheet(f"PushButton {{ background: {color}; color: {'black' if color == '#00FF00' else 'white'}; }}")
|
||||
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)
|
||||
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()
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
工具运行配置: 运行目录定位 / pattern_slots.json(自定义栏位 + 串口记忆)
|
||||
================================================================
|
||||
PyInstaller onefile 打包后 __file__ 指向临时解压目录,
|
||||
配置必须落在 exe 旁边, 故 frozen 时改用 sys.executable 定位。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import dlpc_rs485
|
||||
|
||||
# 可选 Pillow: 装了则支持 JPEG/BMP/WebP, 没装则用 tkinter 原生(仅 PNG/GIF)
|
||||
# Image/ImageTk 在此导入一次, patterns.py 复用同一份对象
|
||||
try:
|
||||
from PIL import Image, ImageTk # noqa: F401
|
||||
HAS_PIL = True
|
||||
except ImportError:
|
||||
Image = ImageTk = None
|
||||
HAS_PIL = False
|
||||
|
||||
# 测试卡区域常量(栏位配置与投屏共用)
|
||||
PAT_W, PAT_H = 640, 360
|
||||
AREA_OPTIONS = ('640x360', '800x600', 'full')
|
||||
DEFAULT_AREA = AREA_OPTIONS[0]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 自定义图案栏位: 配置存于脚本目录 pattern_slots.json,
|
||||
# 图片文件同样保存在脚本目录下
|
||||
# 注: PyInstaller onefile 打包后 __file__ 指向临时解压目录,
|
||||
# 配置必须落在 exe 旁边, 故 frozen 时改用 sys.executable 定位
|
||||
# ============================================================
|
||||
if getattr(sys, 'frozen', False):
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(sys.executable))
|
||||
else:
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
SLOT_COUNT = 10
|
||||
SLOT_CONFIG = os.path.join(BASE_DIR, 'pattern_slots.json')
|
||||
IMG_EXTS = ['.png', '.gif'] + (['.jpg', '.jpeg', '.bmp', '.webp'] if HAS_PIL else [])
|
||||
|
||||
# 光机控制串口记忆(与栏位配置同存 pattern_slots.json 的 "serial" 键)
|
||||
SERIAL_CFG = {'port': '', 'baud': dlpc_rs485.DEFAULT_BAUD,
|
||||
'bytesize': 8, 'parity': 'N', 'stopbits': 1}
|
||||
|
||||
|
||||
def load_slots():
|
||||
"""读 pattern_slots.json, 返回 (slots, area)。
|
||||
|
||||
新格式为 {'area': 'WxH'|'full', 'slots': [...], 'serial': {...}};
|
||||
兼容旧版纯数组格式(无 area)与无 serial 键的中间格式。
|
||||
"""
|
||||
slots = [{'name': f'自定义{i + 1}', 'file': None} for i in range(SLOT_COUNT)]
|
||||
try:
|
||||
with open(SLOT_CONFIG, encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
return slots, DEFAULT_AREA
|
||||
if isinstance(data, dict):
|
||||
area = str(data.get('area') or DEFAULT_AREA)
|
||||
items = data.get('slots') or []
|
||||
ser = data.get('serial')
|
||||
if isinstance(ser, dict):
|
||||
SERIAL_CFG['port'] = str(ser.get('port') or '')
|
||||
try:
|
||||
SERIAL_CFG['baud'] = int(ser.get('baud') or dlpc_rs485.DEFAULT_BAUD)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
SERIAL_CFG['bytesize'] = int(ser.get('bytesize') or 8)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
SERIAL_CFG['parity'] = str(ser.get('parity') or 'N').upper()[:1]
|
||||
try:
|
||||
SERIAL_CFG['stopbits'] = int(ser.get('stopbits') or 1)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
else:
|
||||
area, items = DEFAULT_AREA, data
|
||||
for i, item in enumerate(items[:SLOT_COUNT]):
|
||||
if isinstance(item, dict):
|
||||
slots[i]['name'] = str(item.get('name') or slots[i]['name'])
|
||||
slots[i]['file'] = item.get('file') or None
|
||||
return slots, area
|
||||
|
||||
|
||||
def save_slots(slots, area):
|
||||
with open(SLOT_CONFIG, 'w', encoding='utf-8') as f:
|
||||
json.dump({'area': area, 'slots': slots, 'serial': SERIAL_CFG},
|
||||
f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def copy_image_into_script_dir(src_path):
|
||||
"""把任意路径的图片复制到脚本目录, 重名自动加序号, 返回文件名"""
|
||||
dst_name = os.path.basename(src_path)
|
||||
dst = os.path.join(BASE_DIR, dst_name)
|
||||
if os.path.normcase(os.path.abspath(src_path)) == os.path.normcase(dst):
|
||||
return dst_name
|
||||
stem, ext = os.path.splitext(dst_name)
|
||||
n = 1
|
||||
while os.path.exists(dst):
|
||||
dst_name = f'{stem}_{n}{ext}'
|
||||
dst = os.path.join(BASE_DIR, dst_name)
|
||||
n += 1
|
||||
shutil.copy2(src_path, dst)
|
||||
return dst_name
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Win32 显示器枚举 / 分辨率切换 / 显示拓扑(纯 ctypes, 无第三方依赖)
|
||||
================================================================
|
||||
get_displays() 枚举活动显示器(设备名/显卡/监视器/当前模式/矩形/EDID)
|
||||
enum_modes() 显卡按该屏 EDID 提供的模式列表
|
||||
apply_mode() 临时切换分辨率(不写注册表, 重启还原)
|
||||
ensure_display_extend() 智能拓扑: 仅转接板(EDID 厂商 XLS)接入但未进桌面时强制扩展
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
import winreg
|
||||
|
||||
# ============================================================
|
||||
# Win32 显示 API
|
||||
# ============================================================
|
||||
user32 = ctypes.windll.user32
|
||||
|
||||
ENUM_CURRENT_SETTINGS = 0xFFFFFFFF # (DWORD)-1
|
||||
|
||||
|
||||
class DISPLAY_DEVICEW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('cb', wt.DWORD),
|
||||
('DeviceName', ctypes.c_wchar * 32),
|
||||
('DeviceString', ctypes.c_wchar * 128),
|
||||
('StateFlags', wt.DWORD),
|
||||
('DeviceID', ctypes.c_wchar * 128),
|
||||
('DeviceKey', ctypes.c_wchar * 128),
|
||||
]
|
||||
|
||||
|
||||
class DEVMODEW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('dmDeviceName', ctypes.c_wchar * 32),
|
||||
('dmSpecVersion', wt.WORD),
|
||||
('dmDriverVersion', wt.WORD),
|
||||
('dmSize', wt.WORD),
|
||||
('dmDriverExtra', wt.WORD),
|
||||
('dmFields', wt.DWORD),
|
||||
('dmPositionX', wt.LONG),
|
||||
('dmPositionY', wt.LONG),
|
||||
('dmDisplayOrientation', wt.DWORD),
|
||||
('dmDisplayFixedOutput', wt.DWORD),
|
||||
('dmColor', wt.WORD),
|
||||
('dmDuplex', wt.WORD),
|
||||
('dmYResolution', wt.WORD),
|
||||
('dmTTOption', wt.WORD),
|
||||
('dmCollate', wt.WORD),
|
||||
('dmFormName', ctypes.c_wchar * 32),
|
||||
('dmLogPixels', wt.WORD),
|
||||
('dmBitsPerPel', wt.DWORD),
|
||||
('dmPelsWidth', wt.DWORD),
|
||||
('dmPelsHeight', wt.DWORD),
|
||||
('dmDisplayFlags', wt.DWORD),
|
||||
('dmDisplayFrequency', wt.DWORD),
|
||||
('dmICMMethod', wt.DWORD),
|
||||
('dmICMIntent', wt.DWORD),
|
||||
('dmMediaType', wt.DWORD),
|
||||
('dmDitherType', wt.DWORD),
|
||||
('dmReserved1', wt.DWORD),
|
||||
('dmReserved2', wt.DWORD),
|
||||
('dmPanningWidth', wt.DWORD),
|
||||
('dmPanningHeight', wt.DWORD),
|
||||
]
|
||||
|
||||
|
||||
user32.EnumDisplayDevicesW.argtypes = [ctypes.c_wchar_p, wt.DWORD,
|
||||
ctypes.POINTER(DISPLAY_DEVICEW), wt.DWORD]
|
||||
user32.EnumDisplayDevicesW.restype = wt.BOOL
|
||||
user32.EnumDisplaySettingsW.argtypes = [ctypes.c_wchar_p, wt.DWORD,
|
||||
ctypes.POINTER(DEVMODEW)]
|
||||
user32.EnumDisplaySettingsW.restype = wt.BOOL
|
||||
user32.ChangeDisplaySettingsExW.argtypes = [ctypes.c_wchar_p, ctypes.POINTER(DEVMODEW),
|
||||
ctypes.c_void_p, wt.DWORD, ctypes.c_void_p]
|
||||
user32.ChangeDisplaySettingsExW.restype = ctypes.c_long
|
||||
|
||||
DM_BITSPERPEL = 0x00040000
|
||||
DM_PELSWIDTH = 0x00080000
|
||||
DM_PELSHEIGHT = 0x00100000
|
||||
DM_DISPLAYFREQUENCY = 0x00400000
|
||||
|
||||
|
||||
def read_edid_from_registry(device_id):
|
||||
"""monitor DeviceID 'MONITOR\\<pnp>\\<inst>' → 注册表 EDID 原始字节
|
||||
|
||||
注意: 间接显示器/虚拟显示驱动返回的 DeviceID 实例段是 '{类GUID}\\NNNN'
|
||||
格式, 注册表中并不存在该路径; 因此精确匹配失败后按 PNP ID 枚举其下
|
||||
全部实例, 取第一份 EDID(同一物理屏的重复实例 EDID 相同)。
|
||||
"""
|
||||
if not device_id or not device_id.startswith('MONITOR\\'):
|
||||
return None
|
||||
parts = device_id.split('\\')
|
||||
if len(parts) < 3:
|
||||
return None
|
||||
pnp = parts[1]
|
||||
inst = device_id.split('\\', 2)[2]
|
||||
|
||||
def _read(path):
|
||||
try:
|
||||
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path)
|
||||
try:
|
||||
val, _typ = winreg.QueryValueEx(key, 'EDID')
|
||||
if isinstance(val, (bytes, bytearray)):
|
||||
return bytes(val)
|
||||
if isinstance(val, list): # 某些系统返回 int 列表
|
||||
return bytes(val)
|
||||
finally:
|
||||
winreg.CloseKey(key)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
# 1) 精确实例路径(常规真实显示器)
|
||||
raw = _read(rf'SYSTEM\CurrentControlSet\Enum\DISPLAY\{pnp}\{inst}\Device Parameters')
|
||||
if raw:
|
||||
return raw
|
||||
|
||||
# 2) 按 PNP ID 遍历全部实例(间接显示器/实例名不一致)
|
||||
try:
|
||||
pnp_root = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
|
||||
rf'SYSTEM\CurrentControlSet\Enum\DISPLAY\{pnp}')
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
for i in range(winreg.QueryInfoKey(pnp_root)[0]):
|
||||
sub = winreg.EnumKey(pnp_root, i)
|
||||
raw = _read(rf'SYSTEM\CurrentControlSet\Enum\DISPLAY\{pnp}\{sub}\Device Parameters')
|
||||
if raw:
|
||||
return raw
|
||||
finally:
|
||||
winreg.CloseKey(pnp_root)
|
||||
return None
|
||||
|
||||
|
||||
def get_displays():
|
||||
"""枚举活动显示器: 设备名/显卡/监视器名/当前模式/屏幕矩形/EDID 字节"""
|
||||
out = []
|
||||
i = 0
|
||||
while True:
|
||||
dd = DISPLAY_DEVICEW()
|
||||
dd.cb = ctypes.sizeof(dd)
|
||||
if not user32.EnumDisplayDevicesW(None, i, ctypes.byref(dd), 0):
|
||||
break
|
||||
if dd.StateFlags & 0x1: # ATTACHED_TO_DESKTOP
|
||||
# 找该输出口上活动的 monitor 条目
|
||||
mon = None
|
||||
j = 0
|
||||
while True:
|
||||
m = DISPLAY_DEVICEW()
|
||||
m.cb = ctypes.sizeof(m)
|
||||
if not user32.EnumDisplayDevicesW(dd.DeviceName, j, ctypes.byref(m), 0):
|
||||
break
|
||||
if m.StateFlags & 0x1:
|
||||
mon = m
|
||||
break
|
||||
j += 1
|
||||
|
||||
cur = ''
|
||||
rect = None
|
||||
dm = DEVMODEW()
|
||||
dm.dmSize = ctypes.sizeof(dm)
|
||||
if user32.EnumDisplaySettingsW(dd.DeviceName, ENUM_CURRENT_SETTINGS,
|
||||
ctypes.byref(dm)):
|
||||
cur = f'{dm.dmPelsWidth}x{dm.dmPelsHeight}@{dm.dmDisplayFrequency}'
|
||||
rect = (dm.dmPositionX, dm.dmPositionY, dm.dmPelsWidth, dm.dmPelsHeight)
|
||||
|
||||
out.append({
|
||||
'device': dd.DeviceName, # \\.\DISPLAY1
|
||||
'adapter': dd.DeviceString, # 显卡名
|
||||
'mon_name': mon.DeviceString if mon else '',
|
||||
'edid': read_edid_from_registry(mon.DeviceID) if mon else None,
|
||||
'cur_mode': cur,
|
||||
'rect': rect, # (x, y, w, h) 物理像素
|
||||
})
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
def enum_modes(device):
|
||||
"""枚举 device(\\\\.\\DISPLAYn) 可用模式 —— 显卡按该屏 EDID 提供,
|
||||
返回 (['WxH@Hz', ...] 按像素数降序, {'WxH@Hz': DEVMODEW})"""
|
||||
seen = {}
|
||||
i = 0
|
||||
while True:
|
||||
dm = DEVMODEW()
|
||||
dm.dmSize = ctypes.sizeof(dm)
|
||||
if not user32.EnumDisplaySettingsW(device, i, ctypes.byref(dm)):
|
||||
break
|
||||
i += 1
|
||||
if dm.dmBitsPerPel != 32:
|
||||
continue
|
||||
key = f'{dm.dmPelsWidth}x{dm.dmPelsHeight}@{dm.dmDisplayFrequency}'
|
||||
seen.setdefault(key, dm)
|
||||
keys = sorted(seen, key=lambda k: tuple(int(v) for v in k.replace('@', 'x').split('x')),
|
||||
reverse=True)
|
||||
return keys, seen
|
||||
|
||||
|
||||
def apply_mode(device, dm):
|
||||
"""把 device 切到 dm 模式(临时切换, 不写注册表, 重启还原), 返回 (成功?, 返回码)"""
|
||||
dm.dmFields = DM_BITSPERPEL | DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY
|
||||
r = user32.ChangeDisplaySettingsExW(device, ctypes.byref(dm), None, 0, None)
|
||||
return r == 0, r # DISP_CHANGE_SUCCESSFUL == 0
|
||||
|
||||
|
||||
def ensure_display_extend():
|
||||
"""仅当转接板显示器"已接入但未进入桌面"时, 才强制扩展拓扑。
|
||||
|
||||
场景: 板卡热插拔后, 若 Windows 停留在"仅电脑屏幕"拓扑(会记住上次
|
||||
Win+P 的选择), 板卡的 EDID 虽已安装、GPU 也在按首选时序输出, 但桌面
|
||||
并未扩展过去, get_displays() 就枚举不到这块"显示器" —— 此时才需要
|
||||
强制扩展把它拉回桌面。
|
||||
|
||||
用户主动选择的拓扑(家里的"仅第二屏幕"、手动关闭的显示器等)一律不动:
|
||||
枚举"接入但不在桌面"的显示器, 只有其 EDID 厂商是转接板签名 "XLS"
|
||||
(字节 0x61 0x93, 上游 lt8619c_edid.c) 时才扩展; 笔记本内屏等其它
|
||||
厂商视为用户有意关闭, 不干预。
|
||||
|
||||
返回 SetDisplayConfig 的返回码(0=成功, -1=未触发, 其它=异常); 静默容错。
|
||||
"""
|
||||
try:
|
||||
board_stuck = False
|
||||
i = 0
|
||||
while True:
|
||||
dd = DISPLAY_DEVICEW()
|
||||
dd.cb = ctypes.sizeof(dd)
|
||||
if not user32.EnumDisplayDevicesW(None, i, ctypes.byref(dd), 0):
|
||||
break
|
||||
i += 1
|
||||
j = 0
|
||||
while True:
|
||||
m = DISPLAY_DEVICEW()
|
||||
m.cb = ctypes.sizeof(m)
|
||||
if not user32.EnumDisplayDevicesW(dd.DeviceName, j, ctypes.byref(m), 0):
|
||||
break
|
||||
j += 1
|
||||
if m.StateFlags & 0x1: # ATTACHED_TO_DESKTOP: 已在桌面
|
||||
continue
|
||||
raw = read_edid_from_registry(m.DeviceID)
|
||||
if len(raw or ()) >= 10 and raw[8:10] == b'\x61\x93':
|
||||
board_stuck = True # 转接板没进桌面 -> 需要拉回
|
||||
if not board_stuck:
|
||||
return -1
|
||||
SDC_TOPOLOGY_EXTEND = 0x00000004
|
||||
SDC_APPLY = 0x00000080
|
||||
return ctypes.windll.user32.SetDisplayConfig(
|
||||
0, None, 0, None, SDC_TOPOLOGY_EXTEND | SDC_APPLY)
|
||||
except Exception:
|
||||
return -1
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
#!/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
|
||||
et = str(e)
|
||||
if 'PermissionError' in repr(e) or '拒绝访问' in et \
|
||||
or 'Access is denied' in et:
|
||||
et += '(串口被占用: 检查串口助手/另一个本工具实例)'
|
||||
self.on_status(f'连接失败: {et}')
|
||||
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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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" %*
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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))
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
@@ -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}')
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,70 @@
|
||||
# I2C-Inject — LT8619C 寄存器择机注入工具
|
||||
|
||||
> 更新: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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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]))
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -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 |
@@ -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}')
|
||||
@@ -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`
|
||||
@@ -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()
|
||||
@@ -1,3 +1,53 @@
|
||||
# LT8619C_Debug_Tools
|
||||
|
||||
本仓库用存储LT8619C调试过程中需要的一些小工具(包括AI生成)
|
||||
LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板调试配套上位机工具集。
|
||||
上游主仓库(固件):[LT8619C_DLPC3421_HDMI](https://gitea.hulk.wang/hulk/LT8619C_DLPC3421_HDMI)。
|
||||
|
||||
## 工具一览
|
||||
|
||||
| 工具 | 用途 | 获取 |
|
||||
|---|---|---|
|
||||
| [DLPilot](DLPilot/) | HDMI 源端(EDID 查看/逐字节解析、分辨率切换、纯色与测试图案全屏投屏)+ 光机控制(RS485→GD32 网关→DLPC3421:开关光机/设光强/查温度电流) | 到 [Releases](https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools/releases) 下 `DLPilot-vX.Y.Z-win64.zip`,解压即用;详见 [DLPilot/README.md](DLPilot/README.md) |
|
||||
| [I2C-Inject](I2C-Inject/) | LT8619C 寄存器在线读写:RP2040 注入器在 demo 板 I2C 总线上择机注入(避让主控轮询,写后自动读回校验) | 需配合 RP2040 固件 `rp2040_inject.py`;详见 [I2C-Inject/README.md](I2C-Inject/README.md) |
|
||||
|
||||
## 环境要求
|
||||
|
||||
- 发布版 zip 开箱即用(Windows 10/11 x64,无需安装 Python)
|
||||
- 源码运行:Python ≥3.10;DLPilot 命令行/EDID 纯标准库,GUI 需
|
||||
`py -m pip install PySide6-Fluent-Widgets`(自定义图片 JPG/BMP 可选 Pillow、光机控制可选 pyserial);
|
||||
I2C-Inject 需 `py -m pip install pyserial`
|
||||
- 重新打包:`DLPilot/src/build_exe.py`(自动安装 PyInstaller);I2C-Inject 目录内 `python build_exe.py`
|
||||
|
||||
## 目录结构
|
||||
|
||||
每个工具内统一三区:**src/ 源代码(人写)** → **assets/ 生成资产(可再生成)** →
|
||||
**release/ 打包与发布产物(机器生成)**;README.md 面向使用者,AGENTS.md 面向 AI/Agent(工程约定与构建规程)。
|
||||
|
||||
```
|
||||
LT8619C_Debug_Tools/
|
||||
├── DLPilot/
|
||||
│ ├── src/ # 源码 + 打包/发布脚本 + 图标源
|
||||
│ ├── assets/ # 生成的测试图案 PNG(gen_*.py 产物)
|
||||
│ ├── release/ # 打包 exe + 运行配置(入库);发布 zip(不入库)
|
||||
│ ├── README.md # 使用说明
|
||||
│ └── AGENTS.md # AI/Agent 工程档案
|
||||
├── I2C-Inject/ # PC 端 GUI + RP2040 固件 + 打包 exe + 图标 + README
|
||||
├── LICENSE
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 发布流程(DLPilot)
|
||||
|
||||
```powershell
|
||||
cd DLPilot/src
|
||||
python build_exe.py # 打包 → ../release/DLPilot.exe
|
||||
python package_release.py <版本> # → ../release/DLPilot-v<版本>-win64.zip
|
||||
python publish_gitea.py DLPilot-v<版本>-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN)
|
||||
```
|
||||
|
||||
## 备注
|
||||
|
||||
- 源码与打包产物(exe、pattern_slots.json)均入库;发布 zip 不入库,只上传 Gitea Release
|
||||
- 两件工具的寄存器语义(0x606E 位交换 / 0x606D 整组顺序)均经 demo 板逻辑分析仪
|
||||
双色交叉实测,过程数据与规程存于上游主仓库 `doc/architecture/`
|
||||
- 更新:2026-09-24
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# agent.md — 仓库交接档案(供后续 Agent 接手)
|
||||
|
||||
> **成文方式**:2026-09-20 由一次会话从 git 全部 40 次提交(6243b8c…e354423)、
|
||||
> `DLPilot/AGENTS.md`、各级 README、`.zcode/plans/` 计划文件还原整理;不含任何口头
|
||||
> 讨论内容,一切以仓库工件为准。
|
||||
>
|
||||
> **分工**:本文管"宏观脉络、决策史、待办";工程约定/不变量/构建规程在
|
||||
> `DLPilot/AGENTS.md`(**改代码前必读**);使用说明在根与各工具 README。
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
- 本仓库是 **LT8619C(HDMI→RGB888)→ DLPC3421 投影转接板**的调试上位机工具集,
|
||||
服务上游固件仓 `hulk/LT8619C_DLPC3421_HDMI`(本机路径
|
||||
`E:\Hulk-Coding\LT8619C_DLPC3421_HDMI`,同 Gitea 实例 gitea.hulk.wang)。
|
||||
光机 RS485 协议**唯一权威**是上游仓 `protocols.md`,本仓不维护协议副本。
|
||||
- 两个工具:
|
||||
- **DLPilot**(原 HDMI-Tool)="3D 光固化光机调试台":HDMI 源端(EDID/分辨率/
|
||||
测试图案投屏)+ 光机控制(RS485→板上 GD32E230 网关→DLPC3421)+ 电源检查
|
||||
(PD 自检/FPDO 挡位判定/功率检测)。
|
||||
- **I2C-Inject**:RP2040 固件 + PC GUI,在 demo 板 I2C 总线上择机注入读写 LT8619C
|
||||
寄存器(避让主控 500ms 轮询,写后读回校验)。
|
||||
|
||||
## 2. 当前状态快照(2026-09-24,v0.4.0)
|
||||
|
||||
- 当前入口为 `DLPilot/src/app_fluent.py`,`APP_VERSION = "0.4.0"`;使用
|
||||
PySide6-Fluent-Widgets。旧 `app_gui.py` 保留作历史对照,不再由入口加载。
|
||||
- 本次发布为 `DLPilot-v0.4.0`,源码、文档与 `release/DLPilot.exe` 合并为一个提交;
|
||||
zip 只上传 Gitea Release,不入库。远端完成状态以标签和附件查询为准。
|
||||
- 支持的模式、EDID 信息默认折叠;修复标题栏图标与 PD 成功状态绿色显示。
|
||||
- Light 显式同步 Qt 调色板、容器背景与 Windows 标题栏,浅灰主背景、白色卡片、深色文字;
|
||||
保留 Dark/System 切换。主题不写入用户的 `pattern_slots.json`。
|
||||
- Qt 单文件打包收集 `qfluentwidgets` 资源与系统 `icuuc.dll`,启动注册并预加载
|
||||
Qt/Shiboken DLL,避免 QtCore 导入失败。
|
||||
- 已完成源码编译、无硬件 RS485 自测(ALL PASS)、Qt 离屏浅色渲染检查和正式 EXE
|
||||
定时启动退出(exit=0)。本次原生桌面自动化接口不可用,未完成成品 Light 截图;
|
||||
不应将离屏截图等同于成品完整视觉或硬件验收。
|
||||
- 本机 `release/pattern_slots.json` 的 COM5 运行配置与 `EDID-viewer/` 未跟踪目录
|
||||
不属于本次发布;保留本地文件,提交及发布使用仓库预置配置。
|
||||
|
||||
## 3. 开发方向主线(时间线)
|
||||
|
||||
**阶段 0 · 寄存器语义实测期(09-03)**
|
||||
I2C-Inject + HDMI-Tool 首版落库。核心成果:0x606E 位交换 / 0x606D 整组顺序两个
|
||||
寄存器语义经 demo 板逻辑分析仪双色交叉实测确立(LA 波形判读脚本
|
||||
`la_wave_analyzer*.py` 即为该过程产物,配套 I2C-Inject 固件内置总线嗅探)。
|
||||
|
||||
**阶段 1 · 工程化与发布流水线(09-10)**
|
||||
确立三区目录(src 人写 / assets 可再生 / release 交付)+ 双文档(README 给人 /
|
||||
AGENTS.md 给 Agent);打通 build_exe → package_release → publish_gitea 流水线,
|
||||
发布 HDMI-Tool v0.1.0。同期收录启动强制扩展显示拓扑与波形/注入脚本。
|
||||
|
||||
**阶段 2 · 光机 RS485 控制集成(09-11,v0.2.0)**
|
||||
新增 `dlpc_rs485.py`:帧构造 `D5 01 LEN <ASCII> CRC` / 响应 `B5 TYPE LEN <payload> CRC`
|
||||
(累加和校验)、增量解析、译码表、DLPC485 链路类(daemon 线程+队列+回调,
|
||||
模式照搬 I2C-Inject 的 Injector)。启动拓扑从"无条件扩展"改为**智能判定**。
|
||||
串口下拉显示设备描述(CH344 多口可辨)。计划文件 `.zcode/plans/plan-sess_a1d8*.md`
|
||||
即此阶段方案,已完整执行。
|
||||
|
||||
**阶段 3 · GUI 重构与产品化(09-12,v0.3.0)**
|
||||
feature/ctk-ui 分支完成 CustomTkinter 双栏仪表盘重构(规格:`UI参考布局.svg`);
|
||||
产品更名 **HDMI-Tool → DLPilot**(3D 光固化光机调试台);光机串口参数化(默认
|
||||
8-N-1)+ TEC 控制(M7/M8)与温度实时显示;DLP/Pilot 双行图标。
|
||||
|
||||
**阶段 4 · 体验打磨(09-14,v0.3.1/0.3.2/0.3.3)**
|
||||
投屏退出语义两轮更名("恢复桌面"→"停止投屏"→"停止测试图像");标题栏版本号;
|
||||
清理残余 HDMI-Tool 引用与目录更名;修复 USB 拔出后同参数重连失效(掉线分支
|
||||
清 `_open_key`)。
|
||||
|
||||
**阶段 5 · 电源检查卡(09-17~18,v0.3.4~0.3.6)**
|
||||
- **电源自检**:M301(协议)→M302(挡位电流)→M303(实测电压) 三步帧驱动状态机,
|
||||
FPDO 判定=电压就近 PD 固定挡(5/9/12/15/20/28V ±5%)+ 挡位电流;12V 且 ≥3A
|
||||
绿底,挡位不符红底,无匹配 ERR(CH224Q 电压配置寄存器只写不可读,只能实测判定)。
|
||||
- **功率检测**:M303+M304+M305 三步序列 → 电压 V / 电流 mA / 功率 W(两位小数)。
|
||||
- **M30x 载荷是二进制例外**(SQ52205 实测):大端原始值非 ASCII,必须走
|
||||
`payload_bytes()`;M303=u16 mV、M304=s16 补码 mA、M305=u32 mW(LEN=4)。
|
||||
封装在 `payload_u16_be/payload_s16_be/payload_u32_be/format_power`。
|
||||
- 伴随一串稳定性修复(详见 §4)。
|
||||
|
||||
**阶段 6 · Fluent UI 重写(09-24,v0.4.0)**
|
||||
PySide6-Fluent-Widgets 替换 CustomTkinter 主界面,延续 EDID、测试图案、RS485、
|
||||
PD/功率/温度/电流及自定义槽位功能;GUI 图案窗口使用 Qt,CLI 图案仍走 tkinter。
|
||||
补齐模式/EDID 折叠、PD 成功状态颜色、图标、Light 调色板与 Qt 单文件 DLL 加载。
|
||||
|
||||
## 4. 尝试过 / 推翻过 / 反复调过的思路(勿重蹈)
|
||||
|
||||
| # | 问题 | 最初做法 → 最终做法 / 教训 |
|
||||
|---|---|---|
|
||||
| 1 | 电源自检成功判定 | 先按 ASCII `ok` 前缀判成功 → 实机 M301 回包 payload=`PD` 无前缀被误判错误(9772a2b)→ **改按帧 TYPE=0xF0 译码分类**。教训:M30x 系列回包不能按文本判 |
|
||||
| 2 | 功率值来源 | 先用 M303×M304 计算 P=UI 占位显示(2b7d103,"待固件功率命令")→ 固件提供 M305 直读 u32 mW 后改为三步序列直读(9b29b6a) |
|
||||
| 3 | 二进制载荷三连坑 | ① Frame.payload 解出乱码 → 用 `payload_bytes()` 取原始字节;② M305 是 LEN=4,复用双字节解析会错 → 按 kind 校验长度;③ 日志行含 NUL 导致复制被截断 → 二进制载荷日志行以 hex 展示(e354423) |
|
||||
| 4 | "串口断不开" | 表象是断开逻辑 bug,实际根源是**双实例互抢串口** → 单实例锁 + 串口占用提示(e87bf14) |
|
||||
| 5 | 窗口最大化卡顿 | EDID 信息区 CTkScrollableFrame+40 余个逐行 Label,最大化时逐控件重绘 → 换 CTkTextbox 整体重排,开销低一个量级(30bc224) |
|
||||
| 6 | 启动强制扩展拓扑 | 无条件 `SetDisplayConfig` 扩展会打扰用户自选拓扑(如家里"仅第二屏幕")→ 仅当未激活显示器 EDID 厂商字节为转接板签名 `61 93`("XLS") 时才拉回(d9ecff4) |
|
||||
| 7 | GUI 框架 | 原生 tkinter → CustomTkinter 双栏仪表盘(84838e3);v0.4.0 再迁移到 PySide6-Fluent-Widgets;旧 CTk 文件仅供历史对照 |
|
||||
| 8 | 投屏退出按钮文案 | "恢复桌面"→"停止投屏"→"停止测试图像"(3f35ad2→b167ce3):最终语义是关测试图窗口,与显示拓扑恢复无关 |
|
||||
| 9 | USB 拔出后重连失效 | 掉线分支未清 `_open_key`,同参数重连被"参数未变"逻辑跳过 → 掉线同步清缓存(51e2f1c) |
|
||||
| 10 | 电源自检卡死 | 帧路由引用未定义变量 `payload`(3f36ead),改名/重构时注意帧回调的作用域 |
|
||||
| 11 | Gitea 发布 | python urllib 访问该实例间歇 `SSL: UNEXPECTED_EOF` → 必须 `--insecure`;`GET /releases/tags/{tag}` 的 assets 是纯数组而 `GET /releases/{id}` 是 `{items:[...]}`,publish_gitea.py 已兼容两种形状,勿"简化";请求自动重试已加(f0f8073) |
|
||||
|
||||
## 5. 未实操 / 待办清单(接手者从这里挑活)
|
||||
|
||||
### 5.1 v0.4.0 发布交接
|
||||
v0.4.0 已覆盖未单独发布的 v0.3.6 功能与本次 Fluent UI 改动,无需另发 v0.3.6。
|
||||
发布时先推送代码与同提交标签,再上传 `DLPilot-v0.4.0-win64.zip`;
|
||||
核对远端标签提交、附件名称/大小,并记录验证结果。若需补传,先查询资产,避免重复上传。
|
||||
|
||||
### 5.2 【待硬件】0x606E 的 0x40/0x80 两组位交换
|
||||
demo 板仅实测了 bit5(0x20)=镜像 D[7:0];0x40/0x80 对另外两组**未单独实测**,
|
||||
等自板(转接板)点亮时用 I2C-Inject 验证(见 I2C-Inject/README §3)。
|
||||
|
||||
### 5.3 【待素材】校准图案槽位预置为空
|
||||
测试图案卡第二排 5 个槽位 UI 标注"校准图案(齿轮改图)",为 3D 光固化测试/校准图
|
||||
预留;`release/pattern_slots.json` 中这些槽位 `file: null`。拿到校准图素材后:
|
||||
放 assets/ → 生成脚本入库 → 更新预置 slots → 重新打包。
|
||||
|
||||
### 5.4 【待真机帧】M305 回归向量是算法推演帧
|
||||
`selftest_rs485.py` 中 M303/M304 用真机帧,M305 向量是按协议推演的;拿到真机
|
||||
M305 回包后替换向量,使回归更实。
|
||||
|
||||
### 5.5 README 已同步
|
||||
本次已将根 README 与 DLPilot/README 的安装依赖、界面描述和日期更新到 v0.4.0。
|
||||
|
||||
### 5.6 【明确"不做",非待办】
|
||||
- **TI 原始 I2C 命令透传不做**:GD32 网关固件只收 ASCII M 命令集(v0.2.0 计划的
|
||||
显式决策)。
|
||||
- **不引入 pyserial 硬依赖**:保持 try-import + HAS_* 标志 + 缺失降级的可选依赖
|
||||
模式(Pillow 同理)。
|
||||
|
||||
## 6. 快速上手(完整规程见 DLPilot/AGENTS.md §5/§7)
|
||||
|
||||
```powershell
|
||||
cd DLPilot/src
|
||||
python -m compileall -q . # 编译检查
|
||||
python selftest_rs485.py # 光机协议回归(改 dlpc_rs485.py 后必跑)
|
||||
python hdmi_gui.py --seconds 5 # GUI 冒烟
|
||||
python build_exe.py # 打包 → ../release/DLPilot.exe
|
||||
python package_release.py 0.4.0 # 组装当前版本 zip;新版本先更新 APP_VERSION
|
||||
python publish_gitea.py ../release/DLPilot-v0.4.0-win64.zip --replace --insecure
|
||||
```
|
||||
|
||||
上游协议/寄存器语义问题先查上游仓 `protocols.md` 与 `doc/architecture/`;
|
||||
本仓改动约束(目录契约、12 条代码不变量、领域测试值含义)以 `DLPilot/AGENTS.md`
|
||||
为准,**其中领域语义的测试值(55/AA/FF、640x360、800x600@60)有明确用途,勿顺手改**。
|
||||