1130 lines
53 KiB
Python
1130 lines
53 KiB
Python
#!/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')
|
||
|
||
# 电源自检状态位配色: 灰=未检测, 绿=正常回包, 红=错误/超时
|
||
CHIP_IDLE = ('gray78', 'gray34')
|
||
CHIP_IDLE_TEXT = ('#333333', '#E8E8E8')
|
||
CHIP_OK = '#2FA574'
|
||
CHIP_ERR = '#D9534F'
|
||
|
||
APP_NAME = 'DLPilot'
|
||
APP_VERSION = '0.3.6'
|
||
REPO_URL = 'https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools'
|
||
|
||
|
||
def get_icon_path():
|
||
"""工具图标: 源码模式取 logos/ 下, 打包模式取 exe 内嵌资源(_MEIPASS)"""
|
||
import sys
|
||
if getattr(sys, 'frozen', False):
|
||
p = os.path.join(getattr(sys, '_MEIPASS', ''), 'logo_dlpilot.ico')
|
||
else:
|
||
p = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||
'logos', 'logo_dlpilot.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 _ensure_single_instance():
|
||
"""单实例锁(Windows 命名互斥体): 已有实例在运行时返回 False。
|
||
|
||
双击 exe 开多个实例时, 只有先启动的能连串口, 后续实例连接报"拒绝访问",
|
||
且用户往往只看到后开的窗口 —— 表现为"连不上/断不开"。互斥体句柄随
|
||
进程存活, 进程退出自动释放。"""
|
||
ERROR_ALREADY_EXISTS = 183
|
||
ctypes.windll.kernel32.CreateMutexW(None, False, 'hulk.DLPilot.single')
|
||
return ctypes.windll.kernel32.GetLastError() != ERROR_ALREADY_EXISTS
|
||
|
||
|
||
def run_gui(auto_close=0):
|
||
"""入口: 配置外观并启动主窗口"""
|
||
ctk.set_appearance_mode('System')
|
||
_apply_dropdown_theme()
|
||
if not _ensure_single_instance():
|
||
from tkinter import messagebox
|
||
import tkinter as tk
|
||
r = tk.Tk()
|
||
r.withdraw()
|
||
messagebox.showinfo('DLPilot 已在运行',
|
||
'检测到另一个 DLPilot 实例正在运行。\n'
|
||
'请使用已打开的窗口(串口同时只能被一个实例占用)。')
|
||
return
|
||
App(auto_close).mainloop()
|
||
|
||
|
||
class App(ctk.CTk):
|
||
def __init__(self, auto_close=0):
|
||
super().__init__()
|
||
self.title(f'DLPilot v{APP_VERSION} — 3D 光固化光机调试台 '
|
||
f'(EDID · 测试图案 · RS485 光机控制)')
|
||
try: # 任务栏独立分组
|
||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID('hulk.DLPilot')
|
||
except Exception:
|
||
pass
|
||
self.geometry('1440x1010') # 右列新增电源检查卡, 窗口加高
|
||
self.minsize(1180, 860)
|
||
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,
|
||
on_frame=self._dlpc_on_frame)
|
||
self._dlpc_connected = False
|
||
self._poll_job = None
|
||
self._tl_order = [] # 温度查询应答顺序: 'DMD'/'LAMP' 先到先得
|
||
self._pd_seq = [] # 电源自检待执行步骤: [(命令, kind), ...]
|
||
self._pd_values = {} # 自检采集值: {'ma': 挡位电流, 'mv': 实测电压}
|
||
self._pd_timer = None # 自检单步超时定时器
|
||
self._pwr_seq = [] # 功率检测待执行步骤
|
||
self._pwr_values = {} # 功率检测采集值: {'mv', 'ma'}
|
||
self._pwr_timer = None
|
||
self._build()
|
||
self._load_displays()
|
||
# 主窗口按 ESC 也能关闭所有投屏(投屏窗口失焦时的兜底出口)
|
||
self.bind('<Escape>', lambda _e: patterns.close_all())
|
||
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 信息 --
|
||
# CTkTextbox 而非 CTkScrollableFrame+逐行 Label: 后者 40+ 个画布控件,
|
||
# 窗口最大化时逐个重绘是卡顿主因; 文本框整体重排, 开销低一个量级
|
||
card, c = self._card(col, 'EDID 信息')
|
||
card.grid(row=2, column=0, sticky='nsew', pady=(10, 0))
|
||
self.txt_edid = ctk.CTkTextbox(c, font=MONO, wrap='none')
|
||
self.txt_edid.pack(fill='both', expand=True)
|
||
self.txt_edid.configure(state='disabled')
|
||
|
||
# -- 测试图案 --
|
||
card, c = self._card(col,'测试图案 — 黑底 1:1 居中, 投屏按 ESC 或"停止测试图像"退出',
|
||
button='停止测试图像', command=patterns.close_all)
|
||
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)
|
||
# -- 图案栏位: 两排各 5 个, 等宽 --
|
||
self.slot_buttons = []
|
||
for row_idx, group in enumerate((range(0, 5), range(5, 10))):
|
||
row = ctk.CTkFrame(c, fg_color='transparent')
|
||
row.pack(fill='x', pady=(6, 0))
|
||
for i in group:
|
||
cell = ctk.CTkFrame(row, 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)
|
||
if row_idx == 0:
|
||
ctk.CTkLabel(row, text='点击投图, 齿轮按钮配置图片',
|
||
text_color='gray50').pack(side='left', padx=4)
|
||
else:
|
||
ctk.CTkLabel(row, 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 <payload> CRC, '
|
||
'累加和校验; 全部命令见 README"光机控制"')
|
||
|
||
# -- 电源检查 --
|
||
# 网格对齐两行: col2 为读数框列(weight=1) -> FPDO 色框与功率黑框
|
||
# 起止位置完全一致(等长), 且随窗口拉伸自适应
|
||
card, c = self._card(col, '电源检查 — PD 自检 / 功率读数')
|
||
card.grid(row=2, column=0, sticky='ew', pady=(10, 0))
|
||
pwr_grid = ctk.CTkFrame(c, fg_color='transparent')
|
||
pwr_grid.pack(fill='x')
|
||
pwr_grid.grid_columnconfigure(2, weight=1)
|
||
ctk.CTkButton(pwr_grid, text='电源自检', width=90,
|
||
command=self._pd_selftest).grid(row=0, column=0, sticky='w')
|
||
proto_cell = ctk.CTkFrame(pwr_grid, fg_color='transparent')
|
||
proto_cell.grid(row=0, column=1, sticky='w', padx=(10, 0))
|
||
ctk.CTkLabel(proto_cell, text='协议:').pack(side='left', padx=(0, 4))
|
||
self.chip_pd = ctk.CTkLabel(proto_cell, text='PD', width=60, height=26,
|
||
corner_radius=6, fg_color=CHIP_IDLE,
|
||
text_color=CHIP_IDLE_TEXT)
|
||
self.chip_pd.pack(side='left')
|
||
self.var_fpdo = tk.StringVar(value='FPDO: —')
|
||
self.chip_fpdo = ctk.CTkLabel(pwr_grid, textvariable=self.var_fpdo,
|
||
height=26, corner_radius=6,
|
||
fg_color=CHIP_IDLE,
|
||
text_color=CHIP_IDLE_TEXT, anchor='w', padx=8)
|
||
self.chip_fpdo.grid(row=0, column=2, sticky='ew', padx=(12, 0))
|
||
ctk.CTkButton(pwr_grid, text='功率检测', width=90,
|
||
command=self._pwr_query).grid(row=1, column=0,
|
||
sticky='w', pady=(6, 0))
|
||
self.var_pwr = tk.StringVar(value='电压: — 电流: — 功率: —')
|
||
self.box_pwr = ctk.CTkLabel(pwr_grid, textvariable=self.var_pwr,
|
||
height=26, corner_radius=6,
|
||
fg_color=('#F2F2F2', '#181818'),
|
||
anchor='w', padx=8)
|
||
self.box_pwr.grid(row=1, column=2, sticky='ew', padx=(8, 0), pady=(6, 0))
|
||
|
||
# -- 光机命令 --
|
||
# 标题行右侧: 电流实时读数 + 查电流按钮(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=3, 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('<Return>', 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=4, 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('<Return>', 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('<Return>', 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('<Return>', 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):
|
||
self._set_text(self.txt_edid, '')
|
||
|
||
def _add_edid_row(self, k, v):
|
||
self.txt_edid.configure(state='normal')
|
||
self.txt_edid.insert('end', f'{k:<16}{v}\n')
|
||
self.txt_edid.see('end')
|
||
self.txt_edid.configure(state='disabled')
|
||
|
||
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_frame(self, fr):
|
||
"""结构化应答帧(串口线程): 电源自检/功率检测序列进行中时路由给状态机"""
|
||
if self._pd_seq:
|
||
self._dlpc_log_q.put(('pdframe', fr))
|
||
elif self._pwr_seq:
|
||
self._dlpc_log_q.put(('pwrframe', fr))
|
||
|
||
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))
|
||
# 电流查询应答: okR<n>G<n>B<n> -> 标题行实时读数
|
||
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)
|
||
if text.startswith(('已断开', '串口丢失')):
|
||
self._serial_reset_state()
|
||
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)
|
||
elif kind == 'pdframe':
|
||
self._pd_step_done(text.type_code == 0xF0, text)
|
||
elif kind == 'pwrframe':
|
||
self._pwr_step_done(text)
|
||
else:
|
||
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()
|
||
self._serial_reset_state() # 断开时清序列/定时器/应答归位, 防旧账错配
|
||
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 _serial_reset_state(self):
|
||
"""断开/失连时清理进行中的序列与应答归位队列(不碰串口线程本身)"""
|
||
self._pd_seq.clear()
|
||
self._pwr_seq.clear()
|
||
for t in (self._pd_timer, self._pwr_timer):
|
||
if t:
|
||
try:
|
||
self.after_cancel(t)
|
||
except Exception:
|
||
pass
|
||
self._pd_timer = self._pwr_timer = None
|
||
self._tl_order.clear()
|
||
|
||
def _dlpc_send(self, cmd):
|
||
if not self.dlpc.is_connected():
|
||
self.lbl_dlpc_st.configure(text='未连接串口', text_color='#E07A3F')
|
||
return
|
||
if self._pd_seq or self._pwr_seq:
|
||
# 序列进行中: 手动命令的应答帧会被序列错认领(它不区分应答属于哪条
|
||
# 命令, 来一帧消费一步), 值会错位 —— 拒绝并提示, 序列结束再发
|
||
self._dlpc_log(f'序列进行中, 命令已忽略: {cmd}')
|
||
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 _pd_selftest(self):
|
||
"""电源自检: 依序 M301(协议) → M302(挡位最大电流) → M303(实测母线电压),
|
||
应答帧驱动序列前进(成功=TYPE 0xF0), 结束后按 _pd_fpdo_verdict 判定 FPDO"""
|
||
if not self.dlpc.is_connected():
|
||
self.lbl_dlpc_st.configure(text='未连接串口', text_color='#E07A3F')
|
||
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.configure(fg_color=CHIP_IDLE,
|
||
text_color=CHIP_IDLE_TEXT, text='PD')
|
||
self.chip_fpdo.configure(fg_color=CHIP_IDLE, text_color=CHIP_IDLE_TEXT)
|
||
self.var_fpdo.set('FPDO: —')
|
||
|
||
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 = self.after(3000, lambda: self._pd_step_done(False, None))
|
||
|
||
def _pd_step_done(self, ok, fr):
|
||
if not self._pd_seq:
|
||
return
|
||
if self._pd_timer:
|
||
self.after_cancel(self._pd_timer)
|
||
self._pd_timer = None
|
||
cmd, kind = self._pd_seq.pop(0)
|
||
raw = dlpc_rs485.payload_bytes(fr) if fr is not None else b''
|
||
if ok and kind == 'pd':
|
||
proto = raw.decode('ascii', 'replace').strip()
|
||
good = proto in ('PD', 'EPR')
|
||
self.chip_pd.configure(fg_color=CHIP_OK if good else CHIP_ERR,
|
||
text_color='#FFFFFF', text=proto or 'PD')
|
||
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()
|
||
if kind == 'pd':
|
||
self.chip_pd.configure(fg_color=CHIP_ERR, text_color='#FFFFFF',
|
||
text='ERR')
|
||
elif kind == 'fpdo_ma':
|
||
self.chip_fpdo.configure(fg_color=CHIP_ERR, text_color='#FFFFFF')
|
||
self.var_fpdo.set('FPDO: ERR')
|
||
self._dlpc_log('电源自检: 应答错误/超时, 序列中止')
|
||
|
||
def _pd_fpdo_verdict(self):
|
||
"""FPDO 判定: 电压挡位由 M303 实测母线电压就近判定(标准 PD 固定挡
|
||
5/9/12/15/20/28V, 容差 ±5%; CH224Q 0x0A 只写不可读, 挡位只能实测判定),
|
||
电流为 M302 挡位最大电流。默认挡 12V 且 ≥3A -> 绿底;
|
||
挡位不符(如 9V 挡) -> 红底; 实测电压不落在任何标准挡 -> ERR"""
|
||
mv = self._pd_values.get('mv')
|
||
ma = self._pd_values.get('ma')
|
||
if mv is None or ma is None:
|
||
self.chip_fpdo.configure(fg_color=CHIP_ERR, text_color='#FFFFFF')
|
||
self.var_fpdo.set('FPDO: ERR')
|
||
return
|
||
gear = next((g for g in (5, 9, 12, 15, 20, 28)
|
||
if abs(mv - g * 1000) <= g * 50), None) # ±5% = g*50 mV
|
||
a_txt = f'{ma / 1000:g}'
|
||
if gear is None:
|
||
self.chip_fpdo.configure(fg_color=CHIP_ERR, text_color='#FFFFFF')
|
||
self.var_fpdo.set('FPDO: ERR')
|
||
elif gear == 12 and ma >= 3000:
|
||
self.chip_fpdo.configure(fg_color=CHIP_OK, text_color='#FFFFFF')
|
||
self.var_fpdo.set(f'FPDO: 12V@{a_txt}A')
|
||
else:
|
||
self.chip_fpdo.configure(fg_color=CHIP_ERR, text_color='#FFFFFF')
|
||
self.var_fpdo.set(f'FPDO: {gear}V@{a_txt}A')
|
||
|
||
def _pwr_query(self):
|
||
"""功率检测: M303 电压 + M304 电流(按有符号) + M305 功率(u32 mW)"""
|
||
if not self.dlpc.is_connected():
|
||
self.lbl_dlpc_st.configure(text='未连接串口', text_color='#E07A3F')
|
||
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 = self.after(3000, lambda: self._pwr_step_done(None))
|
||
|
||
def _pwr_step_done(self, fr):
|
||
if not self._pwr_seq:
|
||
return
|
||
if self._pwr_timer:
|
||
self.after_cancel(self._pwr_timer)
|
||
self._pwr_timer = None
|
||
cmd, kind = self._pwr_seq.pop(0)
|
||
# 各步载荷长度: 电压/电流 2 字节, 功率 4 字节(u32)
|
||
want_len = {'mv': 2, 'ma': 2, 'mw': 4}[kind]
|
||
raw = dlpc_rs485.payload_bytes(fr) if fr is not None else b''
|
||
if fr is None or fr.type_code != 0xF0 or len(raw) != want_len:
|
||
self._pwr_seq.clear()
|
||
self.var_pwr.set('电压: ERR 电流: — 功率: —')
|
||
self._dlpc_log('功率检测: 应答错误/超时, 中止')
|
||
return
|
||
if kind == 'mv':
|
||
self._pwr_values['mv'] = dlpc_rs485.payload_u16_be(raw)
|
||
elif kind == 'ma':
|
||
self._pwr_values['ma'] = dlpc_rs485.payload_s16_be(raw)
|
||
elif kind == 'mw':
|
||
self._pwr_values['mw'] = dlpc_rs485.payload_u32_be(raw)
|
||
self._pwr_step_next()
|
||
|
||
def _pwr_update(self, v_mv, ma, mw):
|
||
"""功率读数框更新: 电压 V / 电流 mA / 功率 W(mW 换算, 两位小数; 缺项显示 —)"""
|
||
v = f'{v_mv / 1000:.2f} V' if v_mv is not None else '—'
|
||
a = f'{ma} mA' if ma is not None else '—'
|
||
w = f'{mw / 1000:.2f} W' if mw is not None else '—'
|
||
self.var_pwr.set(f'电压: {v} 电流: {a} 功率: {w}')
|
||
|
||
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)
|
||
if self._pd_timer:
|
||
self.after_cancel(self._pd_timer)
|
||
if self._pwr_timer:
|
||
self.after_cancel(self._pwr_timer)
|
||
except Exception:
|
||
pass
|
||
try: # 两种退出路径(关窗/--seconds 自动关)都收掉串口线程
|
||
self.dlpc.close()
|
||
except Exception:
|
||
pass
|
||
super().destroy()
|