#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ HDMI-Tool 主界面 — CustomTkinter 双栏仪表盘 ================================================================ 布局(HDMI-Tool/UI参考布局.svg): 左列 显示器选择 | 支持的模式 | EDID 信息 | 测试图案/纯色/自定义 | EDID 原始数据 右列 串口选择与参数 | 串口数据(收发日志) | 预设与自定义命令 | 光强/定时快捷滑条 默认深色外观, 顶栏可切换 Dark/Light/System。 本模块只管界面: 显示枚举在 display_win, EDID 解析在 edid_parse, 投屏窗口在 patterns, 串口协议在 dlpc_rs485, 运行配置在 config。 """ import ctypes import os import queue import time import tkinter as tk from tkinter import filedialog, messagebox, ttk 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') 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 run_gui(auto_close=0): """入口: 配置外观并启动主窗口""" ctk.set_appearance_mode('Dark') App(auto_close).mainloop() class App(ctk.CTk): def __init__(self, auto_close=0): super().__init__() self.title('HDMI-Tool — EDID 查看器 + 测试图案 + 光机控制 (LT8619C 转接板调试)') 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._tv_style() 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 _tv_style(self): """内嵌 ttk.Treeview 的暗/亮配色(跟随外观切换)""" self._style = ttk.Style(self) try: self._style.theme_use('clam') except Exception: pass self._apply_tv_colors() def _apply_tv_colors(self): dark = ctk.get_appearance_mode() == 'Dark' s = self._style s.configure('HDMI.Treeview', background='#181818' if dark else '#F4F4F4', fieldbackground='#181818' if dark else '#F4F4F4', foreground='#DADADA' if dark else '#1A1A1A', rowheight=24, borderwidth=0) s.configure('HDMI.Treeview.Heading', background='#242424' if dark else '#E8E8E8', foreground='#B0B0B0' if dark else '#444444', relief='flat') s.map('HDMI.Treeview', background=[('selected', '#2A5D8F')]) def _set_appearance(self, mode): ctk.set_appearance_mode(mode) self._apply_tv_colors() def _card(self, parent, title, button=None, command=None, right_label=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') 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') 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='HDMI-Tool', 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('Dark') seg.pack(side='right') 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=3) # 支持的模式 col.grid_rowconfigure(2, weight=2) # EDID 信息 col.grid_rowconfigure(4, weight=1) # 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.tv_info = ttk.Treeview(c, columns=('k', 'v'), show='headings', height=8, style='HDMI.Treeview') self.tv_info.heading('k', text='项目') self.tv_info.heading('v', text='值') self.tv_info.column('k', width=150, anchor='w') self.tv_info.column('v', anchor='w') sb = ttk.Scrollbar(c, orient='vertical', command=self.tv_info.yview) self.tv_info.configure(yscrollcommand=sb.set) self.tv_info.pack(side='left', fill='both', expand=True) sb.pack(side='right', fill='y') # -- 测试图案 -- 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(config.SLOT_COUNT): 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) # -- EDID 原始数据 -- card, c = self._card(col,'EDID 原始数据', button='逐字节解析', command=self._show_edid_fields) card.grid(row=4, column=0, sticky='nsew', pady=(10, 0)) self.txt_hex = ctk.CTkTextbox(c, font=MONO, wrap='none') self.txt_hex.pack(fill='both', expand=True) 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, width=250, 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') 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) 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"光机控制"') # -- 光机命令 -- card, c = self._card(col,'光机命令 — 预设与自定义') 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'), ('查电流', 'M732'), ('查版本', 'M999'), ('软件重启', 'M888')): ctk.CTkButton(row1, text=name, width=88, command=lambda cc=cmd: self._dlpc_send(cc) ).pack(side='left', padx=(0, 6)) row2 = ctk.CTkFrame(c, fg_color='transparent') row2.pack(fill='x', pady=(6, 0)) ctk.CTkLabel(row2, text='预设:').pack(side='left') self._dlpc_presets = [(n, cmd) for n, cmd, _ in dlpc_rs485.PRESETS] self.cbo_dlpc_cmd = ctk.CTkComboBox( row2, width=240, 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', padx=6) ctk.CTkButton(row2, text='发送', width=64, command=self._dlpc_send_preset).pack(side='left') row3 = ctk.CTkFrame(c, fg_color='transparent') row3.pack(fill='x', pady=(6, 0)) ctk.CTkLabel(row3, text='自定义:').pack(side='left') self.var_custom_cmd = tk.StringVar() ent = ctk.CTkEntry(row3, width=180, textvariable=self.var_custom_cmd, placeholder_text='如 M730S0T2000') ent.pack(side='left', padx=6) ent.bind('', lambda _e: self._dlpc_send_custom()) ctk.CTkButton(row3, text='发送', width=64, command=self._dlpc_send_custom).pack(side='left') # -- 快捷滑条 -- card, c = self._card(col,'快捷滑条 — 光强 / 定时投光') 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='下发 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.StringVar(value='2') sld_t = ctk.CTkSlider(rowb, from_=0, to=32, number_of_steps=32, 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) 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)) # ============================================================ # 显示器 / 分辨率 / 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.tv_info.delete(*self.tv_info.get_children()) if not d['edid']: self.tv_info.insert('', 'end', values=('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.tv_info.insert('', 'end', values=(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 _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_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): self._dlpc_log_q.put(('event', line)) 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) 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()) except ValueError: self.lbl_dlpc_st.configure(text='波特率非法', text_color='#E07A3F') return config.SERIAL_CFG['port'], config.SERIAL_CFG['baud'] = port, baud config.save_slots(self.slots, self.area_var.get()) # 记住本次串口 self.dlpc.open(port, baud) 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-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): 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-32 的整数秒') return self._dlpc_send('M730S0' if v <= 0 else f'M730S0T{v * 1000}') 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()