#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ DLPilot 主界面 — CustomTkinter 双栏仪表盘 ================================================================ 布局(DLPilot/UI参考布局.svg): 左列 显示器选择 | 支持的模式 | EDID 信息 | 测试图案/纯色/自定义 | EDID 原始数据 右列 串口选择与参数 | 串口数据(收发日志) | 预设与自定义命令 | 光强/定时快捷滑条 默认深色外观, 顶栏可切换 Dark/Light/System。 本模块只管界面: 显示枚举在 display_win, EDID 解析在 edid_parse, 投屏窗口在 patterns, 串口协议在 dlpc_rs485, 运行配置在 config。 """ import ctypes import ctypes import os import queue import re import time import tkinter as tk from tkinter import filedialog, messagebox import customtkinter as ctk import config import display_win import dlpc_rs485 import edid_parse import patterns MONO = ('Consolas', 12) BOLD = ('Microsoft YaHei UI', 13, 'bold') TITLE_FONT = ('Microsoft YaHei UI', 18, 'bold') APP_NAME = 'DLPilot' APP_VERSION = '0.3.0' REPO_URL = 'https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools' def get_icon_path(): """工具图标: 源码模式取 logos/ 下, 打包模式取 exe 内嵌资源(_MEIPASS)""" import os import sys if getattr(sys, 'frozen', False): p = os.path.join(getattr(sys, '_MEIPASS', ''), 'logo_ph_orange.ico') else: p = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logos', 'logo_ph_orange.ico') return p if os.path.exists(p) else None def _apply_dropdown_theme(): """CTk 默认下拉菜单是白底, 在深色界面里风格突兀; 改为随外观自适应的配色。 在创建任何控件前调用, 对全部 CTkComboBox/菜单生效。""" ctk.ThemeManager.theme['DropdownMenu']['fg_color'] = ('#F2F2F2', '#2B2B2B') ctk.ThemeManager.theme['DropdownMenu']['hover_color'] = ('#D8D8D8', '#3C3C3C') ctk.ThemeManager.theme['DropdownMenu']['text_color'] = ('#1A1A1A', '#DADADA') def run_gui(auto_close=0): """入口: 配置外观并启动主窗口""" ctk.set_appearance_mode('System') _apply_dropdown_theme() App(auto_close).mainloop() class App(ctk.CTk): def __init__(self, auto_close=0): super().__init__() self.title('DLPilot — 3D 光固化光机调试台 (EDID · 测试图案 · RS485 光机控制)') try: # 任务栏独立分组 ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID('hulk.DLPilot') except Exception: pass self.geometry('1440x920') self.minsize(1180, 800) ic = get_icon_path() if ic: try: self.iconbitmap(ic) # 窗口标题栏图标 except Exception: pass self.displays = [] self._disp_labels = [] self._cur_label = '' self.slots, self.area = config.load_slots() self._modes = {} # 'WxH@Hz' -> DEVMODEW(当前所选屏) self._orig_modes = {} # device -> 切改前的原始 DEVMODEW # 光机控制链路: 回调发生在串口线程, 经队列投递回 GUI 主线程轮询 self._dlpc_log_q = queue.Queue() self.dlpc = dlpc_rs485.DLPC485(self._dlpc_on_event, self._dlpc_on_status) self._dlpc_connected = False self._poll_job = None self._tl_order = [] # 温度查询应答顺序: 'DMD'/'LAMP' 先到先得 self._build() self._load_displays() if dlpc_rs485.HAS_SERIAL: self._poll_dlpc() if auto_close > 0: self.after(auto_close * 1000, self.destroy) # ============================================================ # 布局 # ============================================================ def _set_appearance(self, mode): ctk.set_appearance_mode(mode) def _card(self, parent, title, button=None, command=None, right_label=None, right_widget=None): """卡片容器: 圆角边框 + 标题行(可带右侧小按钮/右对齐标签/自定义控件), 返回 (卡片, 内容 frame) 卡片自身用 grid 参与列布局; 卡片内部(head/content/行控件)一律用 pack。 """ card = ctk.CTkFrame(parent, corner_radius=10, border_width=1, border_color=('gray70', 'gray30')) 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', fill='y') if right_widget is not None: # 自定义控件(如 电流读数+查电流) right_widget.pack(side='right', fill='y') if right_label is not None: ctk.CTkLabel(head, textvariable=right_label, text_color='gray60').pack(side='right', fill='y') if button: ctk.CTkButton(head, text=button, width=92, command=command).pack(side='right', fill='y') content = ctk.CTkFrame(card, fg_color='transparent') content.pack(fill='both', expand=True, padx=12, pady=(2, 10)) return card, content def _build(self): top = ctk.CTkFrame(self, fg_color='transparent') top.pack(fill='x', padx=12, pady=(10, 2)) ctk.CTkLabel(top, text='DLPilot', font=TITLE_FONT).pack(side='left') ctk.CTkLabel(top, text='EDID · 测试图案 · 光机控制', text_color='gray60').pack(side='left', padx=10, pady=(6, 0)) seg = ctk.CTkSegmentedButton(top, values=['Dark', 'Light', 'System'], command=self._set_appearance) seg.set('System') seg.pack(side='right') ctk.CTkButton(top, text='关于', width=48, height=24, fg_color='transparent', border_width=1, border_color=('gray55', 'gray35'), text_color='gray60', hover_color=('gray82', 'gray22'), command=self._show_about).pack(side='right', padx=(0, 12)) body = ctk.CTkFrame(self, fg_color='transparent') body.pack(fill='both', expand=True, padx=12, pady=(4, 12)) 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') right = ctk.CTkFrame(body, fg_color='transparent') right.grid(row=0, column=1, sticky='nsew', padx=(10, 0)) self._build_left(left) if dlpc_rs485.HAS_SERIAL: self._build_right(right) else: hint_card, hint = self._card(right, '光机控制不可用') hint_card.grid(row=0, column=0, sticky='nsew') ctk.CTkLabel(hint, justify='left', wraplength=380, text='未安装 pyserial。\n\n' '执行 py -m pip install pyserial 后重启本工具。' ).pack(expand=True) def _build_left(self, col): col.grid_rowconfigure(1, weight=2) # 支持的模式 col.grid_rowconfigure(2, weight=1) # EDID 信息(压缩高度) col.grid_rowconfigure(4, weight=0) # EDID 原始数据(固定高, 压缩) col.grid_columnconfigure(0, weight=1) # -- 显示器选择区域 -- 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, state='readonly', command=lambda _=None: self._on_select()) 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)) # -- 支持的模式 -- card, c = self._card(col,'支持的模式 (EDID 解算)') card.grid(row=1, column=0, sticky='nsew', pady=(10, 0)) row = ctk.CTkFrame(c, fg_color='transparent') row.pack(fill='x') ctk.CTkLabel(row, text='分辨率:').pack(side='left') self.cbo_mode = ctk.CTkComboBox(row, width=180, state='readonly') self.cbo_mode.pack(side='left', padx=6) ctk.CTkButton(row, text='应用', width=64, command=self._apply_mode).pack(side='left') ctk.CTkButton(row, text='恢复', width=64, 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=150) self.txt_modes.pack(fill='both', expand=True, pady=(8, 0)) self.txt_modes.configure(state='disabled') # -- EDID 信息 -- card, c = self._card(col, 'EDID 信息') card.grid(row=2, column=0, sticky='nsew', pady=(10, 0)) self.edid_scroll = ctk.CTkScrollableFrame(c, height=150) self.edid_scroll.pack(fill='both', expand=True) # -- 测试图案 -- card, c = self._card(col,'测试图案 — 黑底 1:1 居中, 按 ESC 退出') card.grid(row=3, column=0, sticky='ew', pady=(10, 0)) row1 = ctk.CTkFrame(c, fg_color='transparent') row1.pack(fill='x') for key, name in patterns.PATTERN_NAMES.items(): ctk.CTkButton(row1, text=name, width=78, command=lambda k=key: self._show_pattern(k) ).pack(side='left', padx=(0, 6)) ctk.CTkLabel(row1, text='测试卡区域:').pack(side='left', padx=(12, 4)) self.area_var = tk.StringVar(value=self.area) seg_area = ctk.CTkSegmentedButton(row1, values=list(config.AREA_OPTIONS), command=self._save_area) seg_area.set(self.area) seg_area.pack(side='left') row2 = ctk.CTkFrame(c, fg_color='transparent') row2.pack(fill='x', pady=(6, 0)) for ch, items in patterns.SOLID_ROWS: ctk.CTkLabel(row2, text=f'{ch}: 0x').pack(side='left') for label, color in items: ctk.CTkButton(row2, text=label, width=38, height=24, fg_color=color, hover_color=color, text_color='#000000' if color == '#00FF00' else '#FFFFFF', command=lambda cc=color: self._show_solid(cc) ).pack(side='left', padx=3) row3 = ctk.CTkFrame(c, fg_color='transparent') row3.pack(fill='x', pady=(6, 0)) self.slot_buttons = [] for i in range(3): # 自定义栏位(沿用原 3 槽配置) cell = ctk.CTkFrame(row3, fg_color='transparent') cell.pack(side='left', padx=(0, 10)) 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='\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='点击投图, 齿轮按钮配置图片', text_color='gray50').pack(side='left', padx=4) # -- 光校图案(预留 5 槽): 3D 光固化打印测试/校准用图 -- row4 = ctk.CTkFrame(c, fg_color='transparent') row4.pack(fill='x', pady=(6, 0)) for i in range(3, config.SLOT_COUNT): cell = ctk.CTkFrame(row4, fg_color='transparent') cell.pack(side='left', padx=(0, 6)) btn = ctk.CTkButton(cell, text=self.slots[i]['name'], width=84, command=lambda i=i: self._show_custom(i)) btn.pack(side='left') ctk.CTkButton(cell, text='\uE713', width=26, height=24, font=('Segoe MDL2 Assets', 13), command=lambda i=i: self._configure_slot(i) ).pack(side='left', padx=(3, 0)) self.slot_buttons.append(btn) ctk.CTkLabel(row4, text='校准图案(齿轮改图)', text_color='gray50').pack(side='left', padx=4) # -- EDID 原始数据 -- card, c = self._card(col, 'EDID 原始数据', button='逐字节解析', command=self._show_edid_fields) card.grid(row=4, column=0, sticky='ew', pady=(10, 0)) self.txt_hex = ctk.CTkTextbox(c, font=MONO, wrap='none', height=130) self.txt_hex.pack(fill='x') self.txt_hex.configure(state='disabled') def _build_right(self, col): col.grid_rowconfigure(1, weight=1) # 串口数据区域 col.grid_columnconfigure(0, weight=1) # -- 串口选择与参数 -- card, c = self._card(col,'串口 — RS485 → 板载 GD32 网关 → DLPC3421') card.grid(row=0, column=0, sticky='ew') row1 = ctk.CTkFrame(c, fg_color='transparent') row1.pack(fill='x') self._dlpc_ports = dlpc_rs485.DLPC485.list_ports_info() self.cbo_dlpc = ctk.CTkComboBox(row1, state='readonly', values=[lb for _d, lb in self._dlpc_ports]) if config.SERIAL_CFG['port']: self._dlpc_pick(config.SERIAL_CFG['port']) self.cbo_dlpc.pack(side='left', fill='x', expand=True) ctk.CTkButton(row1, text='刷新', width=64, command=self._dlpc_refresh).pack(side='left', padx=(6, 0)) self.btn_dlpc = ctk.CTkButton(row1, text='连接', width=72, command=self._dlpc_toggle) self.btn_dlpc.pack(side='left', padx=(6, 0)) row2 = ctk.CTkFrame(c, fg_color='transparent') row2.pack(fill='x', pady=(6, 0)) ctk.CTkLabel(row2, text='波特率:').pack(side='left') self.cbo_baud = ctk.CTkComboBox(row2, width=110, values=('115200', '57600', '38400', '9600')) self.cbo_baud.set(str(config.SERIAL_CFG['baud'])) self.cbo_baud.pack(side='left', padx=6) ctk.CTkLabel(row2, text='数据位:').pack(side='left') self.cbo_databits = ctk.CTkComboBox(row2, width=62, values=('8', '7', '6', '5')) self.cbo_databits.set(str(config.SERIAL_CFG['bytesize'])) self.cbo_databits.pack(side='left', padx=(6, 0)) ctk.CTkLabel(row2, text='校验:').pack(side='left', padx=(10, 0)) self.cbo_parity = ctk.CTkComboBox(row2, width=54, values=('N', 'E', 'O')) self.cbo_parity.set(str(config.SERIAL_CFG['parity'])) self.cbo_parity.pack(side='left', padx=(6, 0)) ctk.CTkLabel(row2, text='停止位:').pack(side='left', padx=(10, 0)) self.cbo_stopbits = ctk.CTkComboBox(row2, width=54, values=('1', '2')) self.cbo_stopbits.set(str(config.SERIAL_CFG['stopbits'])) self.cbo_stopbits.pack(side='left', padx=(6, 0)) self.lbl_dlpc_st = ctk.CTkLabel(row2, text='未连接', text_color='gray60') self.lbl_dlpc_st.pack(side='left', padx=6) # -- 串口数据区域 -- card, c = self._card(col,'串口数据 — 收发日志(译码 + 原始 hex)', button='清空', command=self._dlpc_clear) card.grid(row=1, column=0, sticky='nsew', pady=(10, 0)) self.txt_dlpc = ctk.CTkTextbox(c, font=MONO) self.txt_dlpc.pack(fill='both', expand=True) self.txt_dlpc.configure(state='disabled') self._dlpc_log('帧格式: D5 01 LEN <命令> CRC / B5 TYPE LEN CRC, ' '累加和校验; 全部命令见 README"光机控制"') # -- 光机命令 -- # 标题行右侧: 电流实时读数 + 查电流按钮(okR..G..B.. 应答解析) cur_frame = ctk.CTkFrame(self, fg_color='transparent') self.var_current = tk.StringVar(value='电流: —') ctk.CTkLabel(cur_frame, textvariable=self.var_current, width=168, anchor='e', text_color='#3B8ED0').pack(side='left') ctk.CTkButton(cur_frame, text='查电流', width=64, height=24, command=self._query_current).pack(side='left', padx=(6, 0)) card, c = self._card(col,'光机命令 — 预设 / 自定义 / TEC', right_widget=cur_frame) card.grid(row=2, column=0, sticky='ew', pady=(10, 0)) row1 = ctk.CTkFrame(c, fg_color='transparent') row1.pack(fill='x') for name, cmd in (('开光机', 'M730S0'), ('关光机', 'M730S1'), ('查版本', 'M999'), ('软件重启', 'M888'), ('开TEC', 'M7S0'), ('关TEC', 'M7S1'), ('TEC33.0°C', 'M8S330')): ctk.CTkButton(row1, text=name, width=64, command=lambda cc=cmd: self._dlpc_send(cc) ).pack(side='left', fill='x', expand=True, padx=(0, 4)) row2 = ctk.CTkFrame(c, fg_color='transparent') row2.pack(fill='x', pady=(6, 0)) row2.grid_columnconfigure(0, weight=6) # 左: 预设(约 6 份) row2.grid_columnconfigure(1, weight=4) # 右: 温度查询+实时显示(约 4 份) lf2 = ctk.CTkFrame(row2, fg_color='transparent') lf2.grid(row=0, column=0, sticky='ew') ctk.CTkLabel(lf2, text='预设:', width=52, anchor='w').pack(side='left') self._dlpc_presets = [(n, cmd) for n, cmd, _ in dlpc_rs485.PRESETS] self.cbo_dlpc_cmd = ctk.CTkComboBox( lf2, state='readonly', values=[f'{n} {cmd}' for n, cmd in self._dlpc_presets]) self.cbo_dlpc_cmd.set(self._dlpc_presets[0][0] + ' ' + self._dlpc_presets[0][1]) self.cbo_dlpc_cmd.pack(side='left', fill='x', expand=True, padx=6) ctk.CTkButton(lf2, text='发送', width=64, command=self._dlpc_send_preset).pack(side='left') rf2 = ctk.CTkFrame(row2, fg_color='transparent') rf2.grid(row=0, column=1, sticky='ew', padx=(8, 0)) ctk.CTkButton(rf2, text='查DMD温度', width=90, command=self._query_dmd).pack(side='left') self.var_dmd = tk.StringVar(value='DMD: —') ctk.CTkLabel(rf2, textvariable=self.var_dmd, width=110, height=28, corner_radius=6, fg_color=('#F2F2F2', '#181818'), anchor='w').pack(side='left', fill='x', expand=True, padx=(6, 0)) row3 = ctk.CTkFrame(c, fg_color='transparent') row3.pack(fill='x', pady=(6, 0)) row3.grid_columnconfigure(0, weight=6) row3.grid_columnconfigure(1, weight=4) lf3 = ctk.CTkFrame(row3, fg_color='transparent') lf3.grid(row=0, column=0, sticky='ew') ctk.CTkLabel(lf3, text='自定义:', width=52, anchor='w').pack(side='left') self.var_custom_cmd = tk.StringVar() ent = ctk.CTkEntry(lf3, textvariable=self.var_custom_cmd, placeholder_text='如 M730S0T2000') ent.pack(side='left', fill='x', expand=True, padx=6) ent.bind('', lambda _e: self._dlpc_send_custom()) ctk.CTkButton(lf3, text='发送', width=64, command=self._dlpc_send_custom).pack(side='left') rf3 = ctk.CTkFrame(row3, fg_color='transparent') rf3.grid(row=0, column=1, sticky='ew', padx=(8, 0)) ctk.CTkButton(rf3, text='查LAMP温度', width=90, command=self._query_lamp).pack(side='left') self.var_lamp = tk.StringVar(value='LAMP: —') ctk.CTkLabel(rf3, textvariable=self.var_lamp, width=110, height=28, corner_radius=6, fg_color=('#F2F2F2', '#181818'), anchor='w').pack(side='left', fill='x', expand=True, padx=(6, 0)) # -- 快捷滑条 -- card, c = self._card(col,'快捷滑条 — 光强 / 定时投光 / TEC 温度') card.grid(row=3, column=0, sticky='ew', pady=(10, 0)) rowa = ctk.CTkFrame(c, fg_color='transparent') rowa.pack(fill='x') ctk.CTkLabel(rowa, text='光强(54-800):').pack(side='left') 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(str(int(round(float(v)))))) sld.set(100) sld.pack(side='left', fill='x', expand=True, padx=8) 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='下发电流值', 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.StringVar(value='2000') sld_t = ctk.CTkSlider(rowb, from_=0, to=32700, number_of_steps=327, command=lambda v: self.var_timer.set(str(int(round(float(v)))))) sld_t.set(2000) sld_t.pack(side='left', fill='x', expand=True, padx=8) 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)) rowc = ctk.CTkFrame(c, fg_color='transparent') rowc.pack(fill='x', pady=(8, 0)) ctk.CTkLabel(rowc, text='TEC凝露点(0.1°C):').pack(side='left') self.var_tec = tk.StringVar(value='330') sld_c = ctk.CTkSlider(rowc, from_=200, to=350, number_of_steps=150, command=lambda v: self.var_tec.set(str(int(round(float(v)))))) sld_c.set(330) sld_c.pack(side='left', fill='x', expand=True, padx=8) self.sld_tec = sld_c self.ent_tec = ctk.CTkEntry(rowc, width=56, justify='center', textvariable=self.var_tec) self.ent_tec.pack(side='left') self.ent_tec.bind('', self._tec_entry_apply) ctk.CTkButton(rowc, text='下发温度值', width=110, command=self._dlpc_send_tec).pack(side='left', padx=(6, 0)) # ============================================================ # 显示器 / 分辨率 / EDID # ============================================================ @staticmethod def _ro(txt): txt.configure(state='disabled') def _set_text(self, txt, content): txt.configure(state='normal') txt.delete('1.0', 'end') txt.insert('1.0', content) txt.configure(state='disabled') def _cur_display(self): if self.cbo.get() in self._disp_labels: return self.displays[self._disp_labels.index(self.cbo.get())] return None def _load_displays(self): prev = self._cur_label self.displays = display_win.get_displays() self._disp_labels = [f"{d['device']} {d['adapter']} [{d['mon_name']}]" for d in self.displays] self.cbo.configure(values=self._disp_labels) if not self.displays: self._cur_label = '' 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] self.cbo.set(self._cur_label) self._on_select() def _on_select(self, _evt=None): if self.cbo.get() not in self._disp_labels: return self._cur_label = self.cbo.get() d = self._cur_display() 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) if d['cur_mode'] in modes: self.cbo_mode.set(d['cur_mode']) elif keys: self.cbo_mode.set(keys[0]) self._clear_edid_rows() if not d['edid']: self._add_edid_row('EDID', '读取失败(注册表无 EDID 数据)') self._set_text(self.txt_modes, '') self._set_text(self.txt_hex, '') return e = edid_parse.parse_edid(d['edid']) for k, v in edid_parse.edid_info_lines(e): self._add_edid_row(k, v) lines = [] for k, t in enumerate(e['dtds']): src = 'DTD 首选' if k == 0 else 'DTD' lines.append(f"{t['hact']}x{t['vact']} @{t['refresh']:g}Hz " f"{t['clk_khz'] / 1000:.2f}MHz {src}") for m in e['cea_video']: lines.append(f'{m} [CEA-VIC]') for m in e['est']: lines.append(f'{m} [已建立时序]') for m in e['std']: lines.append(f'{m} [标准时序]') self._set_text(self.txt_modes, '\n'.join(lines)) self._set_text(self.txt_hex, edid_parse.hex_dump(d['edid'])) def _clear_edid_rows(self): for w in self.edid_scroll.winfo_children(): w.destroy() def _add_edid_row(self, k, v): row = ctk.CTkFrame(self.edid_scroll, fg_color='transparent') row.pack(fill='x', pady=1) ctk.CTkLabel(row, text=k, width=132, anchor='w', text_color='gray60').pack(side='left') ctk.CTkLabel(row, text=v, anchor='w').pack(side='left', fill='x', expand=True) def _apply_mode(self): d = self._cur_display() if d is None or self.cbo_mode.get() not in self._modes: return dev = d['device'] if dev not in self._orig_modes: # 记住首次切改前的原始模式, 供"恢复"用 cur = display_win.DEVMODEW() cur.dmSize = ctypes.sizeof(cur) if display_win.user32.EnumDisplaySettingsW( dev, display_win.ENUM_CURRENT_SETTINGS, ctypes.byref(cur)): self._orig_modes[dev] = cur ok, r = display_win.apply_mode(dev, self._modes[self.cbo_mode.get()]) if not ok: messagebox.showerror('切换失败', f'ChangeDisplaySettingsEx 返回码 {r}') return self.after(500, self._load_displays) # 等模式生效后刷新矩形/EDID 面板 def _restore_mode(self): d = self._cur_display() if d is None: return dm = self._orig_modes.pop(d['device'], None) if dm is None: messagebox.showinfo('无可恢复', '该显示器没有记录切改前的模式') return display_win.apply_mode(d['device'], dm) self.after(500, self._load_displays) def _show_pattern(self, kind): d = self._cur_display() if d and d['rect']: patterns.show_pattern(self, d['rect'], kind, area=self.area_var.get()) def _show_solid(self, color): d = self._cur_display() if d and d['rect']: patterns.show_pattern(self, d['rect'], color, area=self.area_var.get()) def _save_area(self, value): self.area_var.set(value) self.area = value config.save_slots(self.slots, self.area) def _show_custom(self, idx): slot = self.slots[idx] if not slot['file']: messagebox.showinfo('未配置图片', f'请先点击齿轮按钮为"{slot["name"]}"配置图片。') return path = os.path.join(config.BASE_DIR, slot['file']) if not os.path.exists(path): messagebox.showerror('图片不存在', path) return d = self._cur_display() if d and d['rect']: patterns.show_pattern_image(self, d['rect'], path, area=self.area_var.get()) def _configure_slot(self, idx): dlg = ctk.CTkToplevel(self) dlg.title(f'配置自定义栏位 {idx + 1}') dlg.resizable(False, False) dlg.grab_set() dlg.after(150, dlg.grab_set) # CTkToplevel 焦点时序 name_var = tk.StringVar(value=self.slots[idx]['name']) file_var = tk.StringVar(value=self.slots[idx]['file'] or '(未设置)') body = ctk.CTkFrame(dlg, fg_color='transparent') body.pack(padx=16, pady=14) ctk.CTkLabel(body, text='按钮名称:').grid(row=0, column=0, sticky='w', pady=4) ctk.CTkEntry(body, textvariable=name_var, width=240 ).grid(row=0, column=1, columnspan=2, sticky='w', padx=6) ctk.CTkLabel(body, text='图片文件:').grid(row=1, column=0, sticky='w', pady=4) ctk.CTkLabel(body, textvariable=file_var, text_color='#3B8ED0' ).grid(row=1, column=1, columnspan=2, sticky='w', padx=6) if not config.HAS_PIL: ctk.CTkLabel(body, text='(未安装 Pillow, 仅支持 PNG/GIF; ' '安装 pillow 后可用 JPG/BMP/WebP)', text_color='#E07A3F').grid(row=2, column=0, columnspan=3, sticky='w') def choose(): exts = ' '.join(f'*{e}' for e in config.IMG_EXTS) path = filedialog.askopenfilename(parent=dlg, title='选择图片', filetypes=[('图片文件', exts), ('所有文件', '*.*')]) if path: try: file_var.set(config.copy_image_into_script_dir(path)) except OSError as ex: messagebox.showerror('复制图片失败', str(ex), parent=dlg) def save(): name = name_var.get().strip() or f'自定义{idx + 1}' fname = file_var.get() self.slots[idx] = {'name': name, 'file': None if fname.startswith('(') else fname} config.save_slots(self.slots, self.area_var.get()) self.slot_buttons[idx].configure(text=name) dlg.destroy() btns = ctk.CTkFrame(body, fg_color='transparent') btns.grid(row=3 if not config.HAS_PIL else 2, column=0, columnspan=3, sticky='w', pady=(10, 0)) ctk.CTkButton(btns, text='选择图片...', width=110, command=choose).pack(side='left', padx=(0, 6)) ctk.CTkButton(btns, text='清除图片', width=90, command=lambda: file_var.set('(未设置)')).pack(side='left', padx=(0, 6)) ctk.CTkButton(btns, text='保存', width=70, command=save).pack(side='left', padx=(0, 6)) ctk.CTkButton(btns, text='取消', width=70, fg_color='gray40', hover_color='gray25', command=dlg.destroy).pack(side='left') def _show_about(self): dlg = ctk.CTkToplevel(self) dlg.title(f'关于 {APP_NAME}') dlg.resizable(False, False) dlg.after(150, dlg.grab_set()) box = ctk.CTkFrame(dlg, fg_color='transparent') box.pack(padx=26, pady=20) ctk.CTkLabel(box, text=APP_NAME, font=TITLE_FONT, anchor='w').pack(anchor='w') ctk.CTkLabel(box, text=f'v{APP_VERSION} — 3D 光固化光机调试台', text_color='gray60', anchor='w').pack(anchor='w', pady=(0, 12)) for line in ('EDID 查看 / 逐字节解析 / 分辨率切换', '测试图案与校准图全屏投屏', 'RS485 光机控制: 开关光机 / 光强 / 定时 / 温度 / TEC', '配套硬件: DLPilot(HDMI-0.16" DLP 转接板)', '作者: Hulk'): ctk.CTkLabel(box, text='· ' + line, anchor='w').pack(anchor='w', pady=1) ctk.CTkLabel(box, text='仓库: ' + REPO_URL, anchor='w', text_color='#3B8ED0').pack(anchor='w', pady=(12, 0)) ctk.CTkButton(box, text='关闭', width=84, command=dlg.destroy).pack(anchor='e', pady=(14, 0)) def _show_edid_fields(self): d = self._cur_display() if d is None: return if not d['edid']: messagebox.showinfo('无 EDID', '该显示器注册表中没有 EDID 数据, 无法逐字节解析') return dlg = ctk.CTkToplevel(self) dlg.title(f'EDID 逐字节解析 — {d["device"]} [{d["mon_name"]}]') dlg.geometry('1120x700') dlg.after(150, dlg.grab_set()) txt = ctk.CTkTextbox(dlg, font=MONO, wrap='char') txt.pack(fill='both', expand=True, padx=8, pady=(8, 4)) try: # 深底配色(个别 CTk 版本不支持 tag 时退化为单色) txt.tag_config('field', foreground='#E8E8E8') txt.tag_config('raw', foreground='#7F7F7F') txt.tag_config('note', foreground='#9FB8CC') except Exception: pass for off, hx, meaning, val, note in edid_parse.edid_field_rows(d['edid']): try: txt.insert('end', f'{off} {meaning} {val}\n', 'field') if hx: txt.insert('end', f' 原始: {hx}\n', 'raw') if note: txt.insert('end', f' {note}\n', 'note') except tk.TclError: # 个别 CTk 版本不支持 tag 插入, 退化为单色 txt.insert('end', f'{off} {meaning} {val}\n') self._ro(txt) ctk.CTkButton(dlg, text='关闭', width=90, command=dlg.destroy).pack(pady=(0, 8)) # ============================================================ # 光机控制(RS485) # ============================================================ def _dlpc_on_event(self, line): # 温度查询应答: okTL:<0.1°C>, 依发送顺序归入 DMD/LAMP 实时显示框 m = re.search(r'← okTL:(-?\d+)', line) if m and self._tl_order: which = self._tl_order.pop(0) try: text = f'{which}: {int(m.group(1)) / 10:.1f}°C' except ValueError: text = f'{which}: {m.group(1)}' self._dlpc_log_q.put(('temp', text)) # 电流查询应答: okRGB -> 标题行实时读数 m = re.search(r'← okR(\d+)G(\d+)B(\d+)', line) if m: self._dlpc_log_q.put(('current', f'电流 R:{m.group(1)} ' f'G:{m.group(2)} B:{m.group(3)}')) self._dlpc_log_q.put(('event', line)) 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 _dlpc_on_status(self, msg): self._dlpc_log_q.put(('status', msg)) def _poll_dlpc(self): """串口线程 -> GUI 主线程: 80ms 轮询日志队列并同步连接状态""" try: while True: kind, text = self._dlpc_log_q.get_nowait() if kind == 'status': if text.startswith('已连接'): color = '#2FA574' elif ('失败' in text or '丢失' in text or '未' in text or '非法' in text or '丢弃' in text): color = '#E07A3F' else: color = 'gray60' self.lbl_dlpc_st.configure(text=text, text_color=color) elif kind == 'temp': if text.startswith('DMD'): self.var_dmd.set(text) else: self.var_lamp.set(text) elif kind == 'current': self.var_current.set(text) self._dlpc_log(text) except queue.Empty: pass connected = self.dlpc.is_connected() if connected != self._dlpc_connected: self._dlpc_connected = connected self.btn_dlpc.configure(text='断开' if connected else '连接') self._poll_job = self.after(80, self._poll_dlpc) def _dlpc_log(self, line): self.txt_dlpc.configure(state='normal') self.txt_dlpc.insert('end', time.strftime('%H:%M:%S ') + line + '\n') if int(self.txt_dlpc.index('end-1c').split('.')[0]) > 500: self.txt_dlpc.delete('1.0', '2.0') # 只保留最近约 500 行 self.txt_dlpc.see('end') self.txt_dlpc.configure(state='disabled') def _dlpc_clear(self): self._set_text(self.txt_dlpc, '') def _dlpc_refresh(self): self._dlpc_ports = dlpc_rs485.DLPC485.list_ports_info() self.cbo_dlpc.configure(values=[lb for _d, lb in self._dlpc_ports]) devices = [d for d, _l in self._dlpc_ports] if config.SERIAL_CFG['port'] in devices: self._dlpc_pick(config.SERIAL_CFG['port']) elif devices: self._dlpc_pick(devices[0]) def _dlpc_pick(self, device): """按 COM 口名选中下拉项(下拉显示的是带描述的标签)""" for i, (d, label) in enumerate(self._dlpc_ports): if d == device: self.cbo_dlpc.set(label) return def _dlpc_selected_port(self): label = self.cbo_dlpc.get() for d, lb in self._dlpc_ports: if lb == label: return d return label.strip() def _dlpc_toggle(self): if self.dlpc.is_connected(): self.dlpc.close() return port = self._dlpc_selected_port() if not port: self.lbl_dlpc_st.configure(text='未选择串口', text_color='#E07A3F') return try: baud = int(self.cbo_baud.get()) bytesize = int(self.cbo_databits.get()) stopbits = int(self.cbo_stopbits.get()) except ValueError: self.lbl_dlpc_st.configure(text='串口参数非法', text_color='#E07A3F') return parity = self.cbo_parity.get().strip().upper()[:1] or 'N' config.SERIAL_CFG.update(port=port, baud=baud, bytesize=bytesize, parity=parity, stopbits=stopbits) config.save_slots(self.slots, self.area_var.get()) # 记住本次串口 self.dlpc.open(port, baud, bytesize=bytesize, parity=parity, stopbits=stopbits) def _dlpc_send(self, cmd): if not self.dlpc.is_connected(): self.lbl_dlpc_st.configure(text='未连接串口', text_color='#E07A3F') return self.dlpc.send(cmd) def _dlpc_send_preset(self): label = self.cbo_dlpc_cmd.get() for name, cmd in self._dlpc_presets: if label.startswith(name + ' '): self._dlpc_send(cmd) return def _dlpc_send_custom(self): cmd = self.var_custom_cmd.get().strip() 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-32700(ms), 同步回滑条""" 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(32700, v)) self.sld_timer.set(v) self.var_timer.set(str(v)) def _dlpc_send_light(self): 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): try: v = int(self.var_timer.get()) except ValueError: messagebox.showinfo('定时非法', '定时投光需为 0-32700 的整数毫秒(0=不限时)') return self._dlpc_send('M730S0' if v <= 0 else f'M730S0T{v}') def _tec_entry_apply(self, _evt=None): """TEC 温度输入框回车: 校验并限幅 200-350(0.1°C), 同步回滑条""" try: v = int(self.var_tec.get()) except ValueError: self.var_tec.set(str(int(float(self.sld_tec.get())))) return v = max(200, min(350, v)) self.sld_tec.set(v) self.var_tec.set(str(v)) def _dlpc_send_tec(self): try: v = int(self.var_tec.get()) except ValueError: messagebox.showinfo('TEC 温度非法', 'TEC 凝露点需为 200-350 的整数(0.1°C)') return if not 200 <= v <= 350: messagebox.showinfo('TEC 温度越界', 'TEC 凝露点范围 200-350(0.1°C), 越界固件会回 err:OL') return self._dlpc_send(f'M8S{v}') def destroy(self): try: # 取消待触发的轮询回调, 避免销毁后 Tcl 报错 if self._poll_job: self.after_cancel(self._poll_job) except Exception: pass try: # 两种退出路径(关窗/--seconds 自动关)都收掉串口线程 self.dlpc.close() except Exception: pass super().destroy()