diff --git a/HDMI-Tool/release/README.md b/HDMI-Tool/release/README.md index d56f61b..602ea1a 100644 --- a/HDMI-Tool/release/README.md +++ b/HDMI-Tool/release/README.md @@ -20,7 +20,8 @@ | EDID 逐字节解析 | "EDID 原始数据"框右上"逐字节解析"按钮:按偏移逐字段展开基础块与 CTA-861 扩展块,每字段附原始字节 hex;命令行等价 `--edid-fields` | | 分辨率切换 | "分辨率"下拉列出显卡按该屏 EDID 提供的全部模式;**应用**=临时切换(不写注册表,重启/恢复即还原),**恢复**=回到本次会话切改前的模式 | | 测试图案投屏 | 全屏投到所选显示器,测试卡区域可选 `640x360 / 800x600 / full 铺满`;内置 纯红/纯绿/纯蓝/黑白棋盘格/网格线 + R/G/B 三行 55/AA/FF 纯色值按钮 + 3 个自定义图片栏位 | -| 光机控制 | RS485 → 板载 GD32E230 网关 → DLPC3421:开关光机(可定时)、设光强、查电流/温度/版本、TEC 控制、软件重启;收发日志带协议译码(见 §5) | +| 界面 | CustomTkinter 深色双栏仪表盘(左列 HDMI 源端 / 右列 光机控制),顶栏可切换 Dark/Light/System | +| 光机控制 | RS485 → 板载 GD32E230 网关 → DLPC3421:开关光机(可定时)、设光强、查电流/温度/版本、TEC 控制、软件重启;光强/定时提供快捷滑条;收发日志带协议译码(见 §5) | | 智能显示拓扑 | 仅当检测到转接板(EDID 厂商 "XLS")接入但未进入桌面(Windows 卡在"仅电脑屏幕")时才强制扩展把它拉回;你主动选的"仅第二屏幕"等拓扑不会被改动 | | 命令行模式 | 全部功能可脚本化调用,供自动化测试(见 §6、§7) | | 波形分析脚本 | 逻辑分析仪 CSV 波形按占空比签名分段,输出行结构/占空比/消隐明细(见 §7.3) | @@ -45,24 +46,25 @@ HDMI-Tool/ ## 3. GUI 使用 -主窗口自上而下四块区域: +主窗口为双栏仪表盘(布局设计稿:`UI参考布局.svg`),默认深色外观,顶栏可切换 Dark/Light/System: ``` -┌ 显示器: [ ... ▼] [刷新] ┐ -├ 分辨率: [800x600@60 ▼] [应用] [恢复] ├ 显示器/EDID -│ 支持的模式(EDID 解算) | EDID 信息 ┘ -├ 测试图案 — 图案按钮 / R,G,B 0x55,0xAA,0xFF / 测试卡区域 / 自定义栏位 ┐ -├ 光机控制 — 串口 [连接] / 开关光机 / 光强 / 命令下拉 / 自定义命令 ┘ 投屏+光机 -│ └ 收发日志(带协议译码与原始 hex) -└ EDID 原始数据 — hex dump [逐字节解析] ┘ +左列(HDMI 源端) 右列(光机控制 RS485) +┌ 显示器选择区域 [刷新] ┐ ┌ 串口选择 + 波特率 / 连接状态 ┐ +├ 支持的模式: 分辨率[应用][恢复] + 明细 ┐ ├ 串口数据 — 收发日志(译码+hex) ┤ +├ EDID 信息(表格) ┘ ├ 光机命令 — 快捷按钮 │ +├ 测试图案 / 纯色 / 自定义 / 测试卡区域 │ 预设下拉 / 自定义命令输入 │ +├ EDID 原始数据 [逐字节解析] ┘ └ 快捷滑条 — 光强 / 定时投光(秒) ┘ ``` -1. 顶部显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举; -2. **切分辨率**:选模式 → "应用";测完点"恢复"; -3. **投测试图**:先选**测试卡区域**(640x360 / 800x600 / full)→ 点图案按钮全屏投放, - 按 **ESC** 或点击退出;R/G/B 三行纯色按钮(55/AA/FF)用于 RGB 位交换类测试 - (55 与 AA 互为字节镜像对,FF 全高基准);自定义图片点"⚙"配置(图片自动复制到 exe 旁); -4. 区域与自定义栏位配置自动保存在 exe 旁的 `pattern_slots.json`。 +1. 左上显示器下拉选择目标屏(多屏时注意别选成主屏),"刷新"重新枚举; +2. **切分辨率**:"支持的模式"卡片里选模式 → "应用";测完点"恢复"; +3. **投测试图**:"测试图案"卡片先选**测试卡区域**(640x360 / 800x600 / full)→ + 点图案按钮全屏投放,按 **ESC** 或点击退出;R/G/B 纯色按钮(55/AA/FF,按通道着色) + 用于 RGB 位交换类测试;自定义图片点"⚙"配置(图片自动复制到 exe 旁); +4. **光机控制**:右列选串口 → 连接 → 快捷按钮/预设下拉/自定义输入(回车即发); + 快捷滑条直接拖光强后"下发 M731S",拖秒数后"开光机(定时)"(0 秒=不限时 M730S0); +5. 测试卡区域、自定义栏位与串口记忆自动保存在 exe 旁的 `pattern_slots.json`。 ## 4. 典型测试场景 @@ -177,8 +179,13 @@ python la_wave_analyzer3.py # 3 通道(RGB 三组同拍, ch8/ ``` HDMI-Tool/ ├── src/ # 源代码(人工维护区) -│ ├── hdmi_gui.py # 主程序:GUI + 命令行(标准库; pyserial/Pillow 可选) -│ ├── dlpc_rs485.py # 光机 RS485 协议与串口链路(帧/解析/译码/线程) +│ ├── hdmi_gui.py # 入口与命令行模式(argparse 分发) +│ ├── app_gui.py # CustomTkinter 主界面(双栏仪表盘) +│ ├── display_win.py # Win32 显示器枚举/分辨率/智能拓扑(纯标准库) +│ ├── edid_parse.py # EDID 1.4/CEA-861 解析(纯标准库) +│ ├── patterns.py # 测试图案全屏投屏窗口 +│ ├── config.py # 运行目录与 pattern_slots.json 配置 +│ ├── dlpc_rs485.py # 光机 RS485 协议与串口链路 │ ├── selftest_rs485.py # 光机协议自检(无硬件回归) │ ├── build_exe.py # PyInstaller 打包 → release/ │ ├── package_release.py # 组装发布 zip(exe+图案+说明) @@ -197,18 +204,21 @@ HDMI-Tool/ │ ├── *.png / README.md # 附属文件平铺副本(由 package_release.py 从 assets/ 同步) │ └── *.zip # 发布包(不入库, 上传 Gitea Release) ├── README.md # 本说明 -└── AGENTS.md # 面向 AI/Agent 的工程档案(结构/约定/构建规程) +├── AGENTS.md # 面向 AI/Agent 的工程档案(结构/约定/构建规程) +└── UI参考布局.svg/.png # 界面布局设计稿(app_gui.py 按此实现) ``` -从源码运行:`python src/hdmi_gui.py`,需 Python ≥3.10,标准库即可跑 EDID/投屏; -光机控制需 pyserial(`py -m pip install pyserial`),自定义图片要 JPG/BMP/WebP 需可选 Pillow。 +从源码运行:`python src/hdmi_gui.py`,需 Python ≥3.10;EDID/命令行纯标准库; +GUI 需 customtkinter(`py -m pip install customtkinter`);光机控制需 pyserial; +自定义图片要 JPG/BMP/WebP 需可选 Pillow。 ## 9. 构建与发布 ```powershell cd src python selftest_rs485.py # 光机协议回归(改 dlpc_rs485.py 后必跑) -python build_exe.py # 打包 → ../release/HDMI-Tool.exe(自动装 PyInstaller; 内置 pyserial) +python build_exe.py # 打包 → ../release/HDMI-Tool.exe(自动装 PyInstaller; + # 内置 pyserial/customtkinter, CTk 主题数据自动收集) python build_exe.py --debug # 额外出一个带控制台的调试变体(--dump 可见输出) python package_release.py 0.2.0 # 组装 release/HDMI-Tool-v0.2.0-win64.zip python publish_gitea.py HDMI-Tool-v0.2.0-win64.zip --insecure # 上传 Gitea Release(需 GITEA_TOKEN) diff --git a/HDMI-Tool/src/app_gui.py b/HDMI-Tool/src/app_gui.py index db34500..d6ed68d 100644 --- a/HDMI-Tool/src/app_gui.py +++ b/HDMI-Tool/src/app_gui.py @@ -111,8 +111,8 @@ class App(ctk.CTk): ctk.set_appearance_mode(mode) self._apply_tv_colors() - def _card(self, parent, title, button=None, command=None): - """卡片容器: 圆角边框 + 标题行(可带右侧小按钮), 返回 (卡片, 内容 frame) + def _card(self, parent, title, button=None, command=None, right_label=None): + """卡片容器: 圆角边框 + 标题行(可带右侧小按钮/右对齐标签), 返回 (卡片, 内容 frame) 卡片自身用 grid 参与列布局; 卡片内部(head/content/行控件)一律用 pack。 """ @@ -121,6 +121,9 @@ class App(ctk.CTk): head = ctk.CTkFrame(card, fg_color='transparent') head.pack(fill='x', padx=12, pady=(8, 0)) ctk.CTkLabel(head, text=title, font=BOLD, anchor='w').pack(side='left') + if right_label is not None: + ctk.CTkLabel(head, textvariable=right_label, + text_color='gray60').pack(side='right') if button: ctk.CTkButton(head, text=button, width=92, height=24, command=command).pack(side='right') @@ -141,8 +144,8 @@ class App(ctk.CTk): body = ctk.CTkFrame(self, fg_color='transparent') body.pack(fill='both', expand=True, padx=12, pady=(4, 12)) - body.grid_columnconfigure(0, weight=11, uniform='col') - body.grid_columnconfigure(1, weight=9, uniform='col') + body.grid_columnconfigure(0, weight=1, uniform='col') + body.grid_columnconfigure(1, weight=1, uniform='col') body.grid_rowconfigure(0, weight=1) left = ctk.CTkFrame(body, fg_color='transparent') left.grid(row=0, column=0, sticky='nsew') @@ -160,24 +163,22 @@ class App(ctk.CTk): ).pack(expand=True) def _build_left(self, col): - col.grid_rowconfigure(1, weight=2) # 支持的模式 - col.grid_rowconfigure(2, weight=3) # EDID 信息 + col.grid_rowconfigure(1, weight=3) # 支持的模式 + col.grid_rowconfigure(2, weight=2) # EDID 信息 col.grid_rowconfigure(4, weight=1) # EDID 原始数据 col.grid_columnconfigure(0, weight=1) # -- 显示器选择区域 -- - card, c = self._card(col,'显示器选择区域') + self.lbl_cur_var = tk.StringVar(value='当前输出: —') + card, c = self._card(col, '显示器选择区域', right_label=self.lbl_cur_var) card.grid(row=0, column=0, sticky='ew') row = ctk.CTkFrame(c, fg_color='transparent') row.pack(fill='x') - self.cbo = ctk.CTkComboBox(row, width=380, state='readonly', + self.cbo = ctk.CTkComboBox(row, state='readonly', command=lambda _=None: self._on_select()) - self.cbo.pack(side='left') + self.cbo.pack(side='left', fill='x', expand=True) ctk.CTkButton(row, text='刷新', width=64, command=self._load_displays).pack(side='left', padx=(6, 0)) - self.lbl_cur = ctk.CTkLabel(c, text='当前输出: —', anchor='w', - text_color='gray60') - self.lbl_cur.pack(fill='x', pady=(6, 0)) # -- 支持的模式 -- card, c = self._card(col,'支持的模式 (EDID 解算)') @@ -193,7 +194,7 @@ class App(ctk.CTk): command=self._restore_mode).pack(side='left', padx=(6, 0)) ctk.CTkLabel(row, text='临时切换, 重启还原', text_color='gray50').pack(side='left', padx=8) - self.txt_modes = ctk.CTkTextbox(c, font=MONO, height=100) + self.txt_modes = ctk.CTkTextbox(c, font=MONO, height=150) self.txt_modes.pack(fill='both', expand=True, pady=(8, 0)) self.txt_modes.configure(state='disabled') @@ -245,11 +246,12 @@ class App(ctk.CTk): btn = ctk.CTkButton(cell, text=self.slots[i]['name'], width=96, command=lambda i=i: self._show_custom(i)) btn.pack(side='left') - ctk.CTkButton(cell, text='⚙', width=30, height=24, + ctk.CTkButton(cell, text='\uE713', width=30, height=24, + font=('Segoe MDL2 Assets', 13), command=lambda i=i: self._configure_slot(i) ).pack(side='left', padx=(4, 0)) self.slot_buttons.append(btn) - ctk.CTkLabel(row3, text='点击投图, ⚙ 配置图片', + ctk.CTkLabel(row3, text='点击投图, 齿轮按钮配置图片', text_color='gray50').pack(side='left', padx=4) # -- EDID 原始数据 -- @@ -338,23 +340,31 @@ class App(ctk.CTk): rowa = ctk.CTkFrame(c, fg_color='transparent') rowa.pack(fill='x') ctk.CTkLabel(rowa, text='光强(54-800):').pack(side='left') - self.var_light = tk.IntVar(value=100) + self.var_light = tk.StringVar(value='100') sld = ctk.CTkSlider(rowa, from_=54, to=800, number_of_steps=149, - command=lambda v: self.var_light.set(int(round(float(v))))) + command=lambda v: self.var_light.set(str(int(round(float(v)))))) sld.set(100) sld.pack(side='left', fill='x', expand=True, padx=8) - ctk.CTkLabel(rowa, textvariable=self.var_light, width=40).pack(side='left') + self.sld_light = sld + self.ent_light = ctk.CTkEntry(rowa, width=56, justify='center', + textvariable=self.var_light) + self.ent_light.pack(side='left') + self.ent_light.bind('', self._light_entry_apply) ctk.CTkButton(rowa, text='下发 M731S', width=110, command=self._dlpc_send_light).pack(side='left', padx=(6, 0)) rowb = ctk.CTkFrame(c, fg_color='transparent') rowb.pack(fill='x', pady=(8, 0)) ctk.CTkLabel(rowb, text='定时投光(秒):').pack(side='left') - self.var_timer = tk.IntVar(value=2) + self.var_timer = tk.StringVar(value='2') sld_t = ctk.CTkSlider(rowb, from_=0, to=32, number_of_steps=32, - command=lambda v: self.var_timer.set(int(round(float(v))))) + command=lambda v: self.var_timer.set(str(int(round(float(v)))))) sld_t.set(2) sld_t.pack(side='left', fill='x', expand=True, padx=8) - ctk.CTkLabel(rowb, textvariable=self.var_timer, width=40).pack(side='left') + self.sld_timer = sld_t + self.ent_timer = ctk.CTkEntry(rowb, width=56, justify='center', + textvariable=self.var_timer) + self.ent_timer.pack(side='left') + self.ent_timer.bind('', self._timer_entry_apply) ctk.CTkButton(rowb, text='开光机(定时)', width=110, command=self._dlpc_send_timer).pack(side='left', padx=(6, 0)) @@ -384,7 +394,7 @@ class App(ctk.CTk): self.cbo.configure(values=self._disp_labels) if not self.displays: self._cur_label = '' - self.lbl_cur.configure(text='未找到活动显示器') + self.lbl_cur_var.set('未找到活动显示器') return idx = self._disp_labels.index(prev) if prev in self._disp_labels else 0 self._cur_label = self._disp_labels[idx] @@ -396,7 +406,7 @@ class App(ctk.CTk): return self._cur_label = self.cbo.get() d = self._cur_display() - self.lbl_cur.configure(text=f"当前输出: {d['cur_mode']}") + self.lbl_cur_var.set(f"当前输出: {d['cur_mode']}") keys, modes = display_win.enum_modes(d['device']) self._modes = modes self.cbo_mode.configure(values=keys) @@ -474,7 +484,7 @@ class App(ctk.CTk): def _show_custom(self, idx): slot = self.slots[idx] if not slot['file']: - messagebox.showinfo('未配置图片', f'请先点击"⚙"为"{slot["name"]}"配置图片。') + messagebox.showinfo('未配置图片', f'请先点击齿轮按钮为"{slot["name"]}"配置图片。') return path = os.path.join(config.BASE_DIR, slot['file']) if not os.path.exists(path): @@ -671,15 +681,45 @@ class App(ctk.CTk): if cmd: self._dlpc_send(cmd) + def _light_entry_apply(self, _evt=None): + """滑条数值输入框回车: 校验并限幅 54-800, 同步回滑条""" + try: + v = int(self.var_light.get()) + except ValueError: + self.var_light.set(str(int(float(self.sld_light.get())))) + return + v = max(54, min(800, v)) + self.sld_light.set(v) + self.var_light.set(str(v)) + + def _timer_entry_apply(self, _evt=None): + """定时滑条数值输入框回车: 校验并限幅 0-32, 同步回滑条""" + try: + v = int(self.var_timer.get()) + except ValueError: + self.var_timer.set(str(int(float(self.sld_timer.get())))) + return + v = max(0, min(32, v)) + self.sld_timer.set(v) + self.var_timer.set(str(v)) + def _dlpc_send_light(self): - v = int(self.var_light.get()) + try: + v = int(self.var_light.get()) + except ValueError: + messagebox.showinfo('光强非法', '光强需为 54-800 的整数') + return if not 54 <= v <= 800: messagebox.showinfo('光强越界', '光强范围 54-800(越界固件会回 err:OL)') return self._dlpc_send(f'M731S{v}') def _dlpc_send_timer(self): - v = int(self.var_timer.get()) + try: + v = int(self.var_timer.get()) + except ValueError: + messagebox.showinfo('定时非法', '定时投光需为 0-32 的整数秒') + return self._dlpc_send('M730S0' if v <= 0 else f'M730S0T{v * 1000}') def destroy(self):