#!/usr/bin/env python3 # -*- coding: utf-8 -*- """DLPilot Fluent UI. The device, EDID and RS485 modules deliberately remain unchanged. This file is the presentation layer for the current desktop application and uses PySide6-Fluent-Widgets for the cards, controls and dialogs. """ import ctypes import os import queue import re import sys import time # PyInstaller keeps Shiboken's ABI DLLs in a sibling directory. Register both # Qt and Shiboken before importing QtCore; this is required by one-file builds # on Windows/Python 3.14 where the normal package import hook is unavailable. if getattr(sys, "frozen", False) and os.name == "nt": _frozen_dll_dirs = [] for _dll_dir in (os.path.join(getattr(sys, "_MEIPASS", ""), "PySide6"), os.path.join(getattr(sys, "_MEIPASS", ""), "shiboken6")): if os.path.isdir(_dll_dir): _frozen_dll_dirs.append(_dll_dir) try: os.add_dll_directory(_dll_dir) except (AttributeError, OSError): pass # Python 3.14 extension loading may ignore AddDllDirectory for a pyd's # transitive dependencies; PATH keeps QtCore.pyd compatible with both # Python 3.11-3.14 and PyInstaller one-file extraction. if _frozen_dll_dirs: os.environ["PATH"] = os.pathsep.join(_frozen_dll_dirs + [os.environ.get("PATH", "")]) # Preload the two libraries used by QtCore.pyd. This also covers Windows # loader configurations that do not apply PATH/AddDllDirectory to a pyd's # transitive dependencies after PyInstaller extraction. try: ctypes.WinDLL(os.path.join(getattr(sys, "_MEIPASS", ""), "shiboken6", "shiboken6.abi3.dll")) ctypes.WinDLL(os.path.join(getattr(sys, "_MEIPASS", ""), "PySide6", "Qt6Core.dll")) except (AttributeError, OSError): pass from PySide6.QtCore import QPoint, QRect, Qt, QTimer from PySide6.QtGui import QColor, QFont, QIcon, QImage, QPainter, QPen, QPalette from PySide6.QtWidgets import ( QApplication, QDialog, QFileDialog, QFormLayout, QGridLayout, QHBoxLayout, QLabel, QMainWindow, QMessageBox, QPlainTextEdit, QScrollArea, QSplitter, QToolButton, QVBoxLayout, QWidget, ) from qfluentwidgets import ( BodyLabel, CardWidget, ComboBox, LineEdit, PlainTextEdit, PrimaryPushButton, PushButton, Slider, StrongBodyLabel, Theme, setTheme, ) import config import display_win import dlpc_rs485 import edid_parse import patterns APP_NAME = "DLPilot" APP_VERSION = "0.4.0" REPO_URL = "https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools" MONO = QFont("Consolas", 10) CHIP_IDLE = "#666666" CHIP_OK = "#2FA574" CHIP_ERR = "#D9534F" _QT_PATTERNS = set() def close_qt_patterns(): for window in list(_QT_PATTERNS): window.close() _QT_PATTERNS.clear() patterns.close_all() def _area_box(spec, width, height): if spec == "full": return 0, 0, width, height try: aw, ah = (int(v) for v in spec.lower().split("x", 1)) except Exception: aw, ah = config.PAT_W, config.PAT_H return (width - aw) // 2, (height - ah) // 2, aw, ah class PatternWindow(QWidget): """Borderless Qt equivalent of the old tkinter test-pattern window.""" def __init__(self, rect, kind, area, image_path=None, parent=None): super().__init__(parent) self.kind = kind self.area = area self.image = QImage(image_path) if image_path else None x, y, width, height = rect self.setGeometry(x, y, width, height) self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool) self.setAttribute(Qt.WA_DeleteOnClose) _QT_PATTERNS.add(self) self.show() self.activateWindow() self.setFocus() def keyPressEvent(self, event): if event.key() == Qt.Key_Escape: self.close() else: super().keyPressEvent(event) def closeEvent(self, event): _QT_PATTERNS.discard(self) super().closeEvent(event) def paintEvent(self, _event): painter = QPainter(self) painter.fillRect(self.rect(), Qt.black) x0, y0, aw, ah = _area_box(self.area, self.width(), self.height()) if self.image and not self.image.isNull(): painter.drawImage(QPoint(x0 + (aw - self.image.width()) // 2, y0 + (ah - self.image.height()) // 2), self.image) return if self.kind.startswith("#"): painter.fillRect(x0, y0, aw, ah, QColor(self.kind)) elif self.kind in {"Red", "Green", "Blue"}: painter.fillRect(x0, y0, aw, ah, QColor({"Red": "#ff0000", "Green": "#00ff00", "Blue": "#0000ff"}[self.kind])) elif self.kind == "Checker": for gy in range((ah + 39) // 40): for gx in range((aw + 39) // 40): if (gx + gy) % 2 == 0: painter.fillRect(x0 + gx * 40, y0 + gy * 40, 40, 40, Qt.white) elif self.kind == "Grid": painter.setPen(QPen(Qt.white, 1)) for x in range(0, aw + 1, 20): painter.drawLine(x0 + x, y0, x0 + x, y0 + ah) for y in range(0, ah + 1, 20): painter.drawLine(x0, y0 + y, x0 + aw, y0 + y) def _single_instance(): if os.name != "nt": return True try: ctypes.windll.kernel32.CreateMutexW(None, False, "hulk.DLPilot.single") return ctypes.windll.kernel32.GetLastError() != 183 except Exception: return True class App(QMainWindow): def __init__(self, auto_close=0): super().__init__() app = QApplication.instance() self._system_palette = app.palette() if app else QPalette() self._system_style = app.styleSheet() if app else "" self.setWindowTitle(f"{APP_NAME} v{APP_VERSION} — 3D 光固化光机调试台") icon_path = self._icon_path() if icon_path: self.setWindowIcon(QIcon(icon_path)) self.resize(1440, 960) self.setMinimumSize(1180, 760) self.displays = [] self._disp_labels = [] self._modes = {} self._orig_modes = {} self._cur_label = "" self.slots, self.area = config.load_slots() self._dlpc_log_q = queue.Queue() self.dlpc = dlpc_rs485.DLPC485(self._dlpc_on_event, self._dlpc_on_status, on_frame=self._dlpc_on_frame) self._dlpc_connected = False self._tl_order = [] self._pd_seq = [] self._pd_values = {} self._pd_timer = None self._pwr_seq = [] self._pwr_values = {} self._pwr_timer = None self._build() self._load_displays() self._timer = QTimer(self) self._timer.timeout.connect(self._poll_dlpc) if dlpc_rs485.HAS_SERIAL: self._timer.start(80) if auto_close > 0: QTimer.singleShot(auto_close * 1000, self.close) @staticmethod def _icon_path(): base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__))) path = os.path.join(base, "logo_dlpilot.ico") if os.path.exists(path): return path path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logos", "logo_dlpilot.ico") return path if os.path.exists(path) else None def _button(self, text, callback, primary=False): button = PrimaryPushButton(text) if primary else PushButton(text) button.clicked.connect(callback) return button def _card(self, title, parent): card = CardWidget(parent) layout = QVBoxLayout(card) layout.setContentsMargins(14, 12, 14, 14) layout.setSpacing(8) row = QHBoxLayout() row.addWidget(StrongBodyLabel(title, card)) row.addStretch(1) layout.addLayout(row) return card, layout def _collapsible_card(self, title, parent, expanded=False): """Card whose body starts collapsed to keep the dashboard compact.""" card = CardWidget(parent) layout = QVBoxLayout(card) layout.setContentsMargins(8, 6, 8, 8) layout.setSpacing(4) header = QToolButton(card) header.setText(title) header.setCheckable(True) header.setChecked(expanded) header.setAutoRaise(True) header.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) header.setArrowType(Qt.DownArrow if expanded else Qt.RightArrow) header.setStyleSheet("QToolButton { text-align: left; font-weight: 700; padding: 6px; }") layout.addWidget(header) body = QWidget(card) body_layout = QVBoxLayout(body) body_layout.setContentsMargins(6, 0, 6, 4) body_layout.setSpacing(8) body.setVisible(expanded) layout.addWidget(body) def toggle(checked): body.setVisible(checked) header.setArrowType(Qt.DownArrow if checked else Qt.RightArrow) card.adjustSize() header.clicked.connect(toggle) return card, body_layout def _text(self, read_only=True, mono=True): text = PlainTextEdit(self) text.setReadOnly(read_only) if mono: text.setFont(MONO) text.setLineWrapMode(QPlainTextEdit.NoWrap) return text def _build(self): root = QWidget(self) root.setObjectName("root") outer = QVBoxLayout(root) outer.setContentsMargins(18, 14, 18, 18) outer.setSpacing(12) header = QHBoxLayout() title = StrongBodyLabel("DLPilot", root) title.setStyleSheet("font-size: 24px; font-weight: 700;") header.addWidget(title) header.addWidget(BodyLabel("EDID · 测试图案 · RS485 光机控制", root)) header.addStretch(1) self.theme = ComboBox(root) self.theme.addItems(["Dark", "Light", "System"]) self.theme.setCurrentText("System") self.theme.currentTextChanged.connect(self._set_theme) header.addWidget(self.theme) header.addWidget(self._button("关于", self._show_about)) outer.addLayout(header) split = QSplitter(Qt.Horizontal, root) split.setChildrenCollapsible(False) split.addWidget(self._scroll_column(self._build_left)) split.addWidget(self._scroll_column(self._build_right)) split.setStretchFactor(0, 1) split.setStretchFactor(1, 1) outer.addWidget(split, 1) self.setCentralWidget(root) def _scroll_column(self, builder): host = QWidget() layout = QVBoxLayout(host) layout.setContentsMargins(2, 2, 8, 2) layout.setSpacing(10) builder(layout) layout.addStretch(1) scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setFrameShape(QScrollArea.NoFrame) scroll.setWidget(host) if builder.__name__ == '_build_left': self.left_scroll = scroll else: self.right_scroll = scroll return scroll def _build_left(self, layout): card, body = self._card("显示器选择", self) row = QHBoxLayout() self.cbo_display = ComboBox(card) self.cbo_display.currentTextChanged.connect(self._on_select) self.lbl_cur = BodyLabel("当前输出: —", card) row.addWidget(self.cbo_display, 1) row.addWidget(self._button("刷新", self._load_displays)) body.addLayout(row) body.addWidget(self.lbl_cur) layout.addWidget(card) card, body = self._collapsible_card("支持的模式(EDID 解算)", self) row = QHBoxLayout() self.cbo_mode = ComboBox(card) row.addWidget(BodyLabel("分辨率:", card)) row.addWidget(self.cbo_mode, 1) row.addWidget(self._button("应用", self._apply_mode, True)) row.addWidget(self._button("恢复", self._restore_mode)) body.addLayout(row) self.txt_modes = self._text() self.txt_modes.setMinimumHeight(135) body.addWidget(self.txt_modes) layout.addWidget(card) card, body = self._collapsible_card("EDID 信息", self) self.txt_edid = self._text() self.txt_edid.setMinimumHeight(150) body.addWidget(self.txt_edid) layout.addWidget(card) card, body = self._card("测试图案", self) row = QHBoxLayout() for key, name in patterns.PATTERN_NAMES.items(): row.addWidget(self._button(name, lambda checked=False, k=key: self._show_pattern(k))) row.addWidget(self._button("停止", close_qt_patterns)) body.addLayout(row) area_row = QHBoxLayout() area_row.addWidget(BodyLabel("测试卡区域:", card)) self.cbo_area = ComboBox(card) self.cbo_area.addItems(list(config.AREA_OPTIONS)) self.cbo_area.setCurrentText(self.area) self.cbo_area.currentTextChanged.connect(self._save_area) area_row.addWidget(self.cbo_area) area_row.addStretch(1) body.addLayout(area_row) for channel, items in patterns.SOLID_ROWS: row = QHBoxLayout() row.addWidget(BodyLabel(f"{channel}: 0x", card)) for label, color in items: button = PushButton(label, card) button.setStyleSheet(f"PushButton {{ background: {color}; color: {'black' if color == '#00FF00' else 'white'}; }}") button.clicked.connect(lambda checked=False, c=color: self._show_solid(c)) row.addWidget(button) row.addStretch(1) body.addLayout(row) self.slot_buttons = [] grid = QGridLayout() for i in range(config.SLOT_COUNT): cell = QWidget(card) cell_layout = QHBoxLayout(cell) cell_layout.setContentsMargins(0, 0, 0, 0) button = self._button(self.slots[i]["name"], lambda checked=False, n=i: self._show_custom(n)) gear = self._button("⚙", lambda checked=False, n=i: self._configure_slot(n)) cell_layout.addWidget(button, 1) cell_layout.addWidget(gear) grid.addWidget(cell, i // 2, i % 2) self.slot_buttons.append(button) body.addLayout(grid) layout.addWidget(card) card, body = self._card("EDID 原始数据", self) self.txt_hex = self._text() self.txt_hex.setMinimumHeight(130) body.addWidget(self.txt_hex) body.addWidget(self._button("逐字节解析", self._show_edid_fields)) layout.addWidget(card) def _build_right(self, layout): if not dlpc_rs485.HAS_SERIAL: card, body = self._card("光机控制不可用", self) body.addWidget(BodyLabel("未安装 pyserial,请执行 py -m pip install pyserial。")) layout.addWidget(card) return card, body = self._card("串口 · RS485 → GD32 网关 → DLPC3421", self) row = QHBoxLayout() self._dlpc_ports = dlpc_rs485.DLPC485.list_ports_info() self.cbo_dlpc = ComboBox(card) self.cbo_dlpc.addItems([label for _device, label in self._dlpc_ports]) if config.SERIAL_CFG["port"]: self._dlpc_pick(config.SERIAL_CFG["port"]) row.addWidget(self.cbo_dlpc, 1) row.addWidget(self._button("刷新", self._dlpc_refresh)) self.btn_dlpc = self._button("连接", self._dlpc_toggle, True) row.addWidget(self.btn_dlpc) body.addLayout(row) params = QHBoxLayout() for caption, attr, values, default in ( ("波特率", "cbo_baud", ["115200", "57600", "38400", "9600"], str(config.SERIAL_CFG["baud"])), ("数据位", "cbo_databits", ["8", "7", "6", "5"], str(config.SERIAL_CFG["bytesize"])), ("校验", "cbo_parity", ["N", "E", "O"], str(config.SERIAL_CFG["parity"])), ("停止位", "cbo_stopbits", ["1", "2"], str(config.SERIAL_CFG["stopbits"])), ): params.addWidget(BodyLabel(caption, card)) combo = ComboBox(card) combo.addItems(values) combo.setCurrentText(default) setattr(self, attr, combo) params.addWidget(combo) self.lbl_dlpc_st = BodyLabel("未连接", card) params.addWidget(self.lbl_dlpc_st) params.addStretch(1) body.addLayout(params) layout.addWidget(card) card, body = self._card("串口数据 · 收发日志(译码 + 原始 hex)", self) row = QHBoxLayout() row.addStretch(1) row.addWidget(self._button("清空", self._dlpc_clear)) body.addLayout(row) self.txt_dlpc = self._text() self.txt_dlpc.setMinimumHeight(230) body.addWidget(self.txt_dlpc) self._dlpc_log("帧格式: D5 01 LEN <命令> CRC / B5 TYPE LEN CRC") layout.addWidget(card) card, body = self._card("电源检查 · PD 自检 / 功率读数", self) self.chip_pd = QLabel("PD", card) self.chip_fpdo = QLabel("FPDO: —", card) self.var_pwr = "电压: — 电流: — 功率: —" self.box_pwr = QLabel(self.var_pwr, card) for widget in (self.chip_pd, self.chip_fpdo, self.box_pwr): widget.setMinimumHeight(30) widget.setAlignment(Qt.AlignCenter | Qt.AlignVCenter) widget.setStyleSheet(f"background: {CHIP_IDLE}; color: white; border-radius: 6px; padding: 4px 8px;") row = QHBoxLayout() row.addWidget(self._button("电源自检", self._pd_selftest)) row.addWidget(self.chip_pd) row.addWidget(self.chip_fpdo, 1) body.addLayout(row) row = QHBoxLayout() row.addWidget(self._button("功率检测", self._pwr_query)) row.addWidget(self.box_pwr, 1) body.addLayout(row) layout.addWidget(card) card, body = self._card("光机命令 · 预设 / 自定义 / 温度", self) row = QHBoxLayout() self.var_current = BodyLabel("电流: —", card) row.addWidget(self.var_current) row.addStretch(1) row.addWidget(self._button("查电流", self._query_current)) body.addLayout(row) preset_row = QHBoxLayout() self._dlpc_presets = [(n, cmd) for n, cmd, _ in dlpc_rs485.PRESETS] self.cbo_dlpc_cmd = ComboBox(card) self.cbo_dlpc_cmd.addItems([f"{n} {cmd}" for n, cmd in self._dlpc_presets]) preset_row.addWidget(BodyLabel("预设:", card)) preset_row.addWidget(self.cbo_dlpc_cmd, 1) preset_row.addWidget(self._button("发送", self._dlpc_send_preset, True)) preset_row.addWidget(self._button("查 DMD 温度", self._query_dmd)) self.var_dmd = BodyLabel("DMD: —", card) preset_row.addWidget(self.var_dmd) body.addLayout(preset_row) custom_row = QHBoxLayout() self.var_custom_cmd = LineEdit(card) self.var_custom_cmd.setPlaceholderText("如 M730S0T2000") self.var_custom_cmd.returnPressed.connect(self._dlpc_send_custom) custom_row.addWidget(BodyLabel("自定义:", card)) custom_row.addWidget(self.var_custom_cmd, 1) custom_row.addWidget(self._button("发送", self._dlpc_send_custom)) custom_row.addWidget(self._button("查 LAMP 温度", self._query_lamp)) self.var_lamp = BodyLabel("LAMP: —", card) custom_row.addWidget(self.var_lamp) body.addLayout(custom_row) layout.addWidget(card) card, body = self._card("快捷滑条 · 光强 / 定时投光 / TEC 温度", self) self.sld_light, self.ent_light = self._slider_row(body, "光强 (54-800)", 54, 800, 100, self._dlpc_send_light, "下发电流值") self.sld_timer, self.ent_timer = self._slider_row(body, "定时投光 (毫秒)", 0, 32700, 2000, self._dlpc_send_timer, "开光机(定时)") self.sld_tec, self.ent_tec = self._slider_row(body, "TEC 凝露点 (0.1°C)", 200, 350, 330, self._dlpc_send_tec, "下发温度值") layout.addWidget(card) def _slider_row(self, layout, text, low, high, value, callback, button_text): row = QHBoxLayout() row.addWidget(BodyLabel(text, self)) slider = Slider(Qt.Horizontal, self) slider.setRange(low, high) slider.setValue(value) entry = LineEdit(self) entry.setText(str(value)) entry.setFixedWidth(65) slider.valueChanged.connect(lambda v: entry.setText(str(v))) entry.returnPressed.connect(lambda: self._sync_slider(slider, entry, low, high)) row.addWidget(slider, 1) row.addWidget(entry) row.addWidget(self._button(button_text, callback)) layout.addLayout(row) return slider, entry @staticmethod def _sync_slider(slider, entry, low, high): try: value = int(entry.text()) except ValueError: value = slider.value() value = max(low, min(high, value)) slider.setValue(value) entry.setText(str(value)) def _set_theme(self, value): app = QApplication.instance() setTheme({"Dark": Theme.DARK, "Light": Theme.LIGHT, "System": Theme.AUTO}[value]) if value == "System": app.setPalette(self._system_palette) app.setStyleSheet(self._system_style) is_dark = app.palette().color(QPalette.Window).value() < 128 else: is_dark = value == "Dark" self._apply_palette(is_dark) self._apply_native_titlebar(is_dark) def _apply_palette(self, is_dark): """Keep native Qt widgets and Fluent cards on the same surface colors.""" app = QApplication.instance() palette = QPalette() if is_dark: window, base, text, border = "#202020", "#2b2b2b", "#f5f5f5", "#3a3a3a" accent = "#00c8d7" else: window, base, text, border = "#f4f6f8", "#ffffff", "#1f2328", "#d8dee4" accent = "#0f6cbd" for role in (QPalette.Window, QPalette.Base, QPalette.AlternateBase): palette.setColor(role, QColor(window if role == QPalette.Window else base)) palette.setColor(QPalette.Text, QColor(text)) palette.setColor(QPalette.WindowText, QColor(text)) palette.setColor(QPalette.Button, QColor(base)) palette.setColor(QPalette.ButtonText, QColor(text)) palette.setColor(QPalette.PlaceholderText, QColor("#8b949e" if not is_dark else "#8a8a8a")) palette.setColor(QPalette.Highlight, QColor(accent)) palette.setColor(QPalette.HighlightedText, QColor("#ffffff")) palette.setColor(QPalette.ToolTipBase, QColor(base)) palette.setColor(QPalette.ToolTipText, QColor(text)) app.setPalette(palette) app.setStyleSheet(f""" QMainWindow, QWidget#root {{ background-color: {window}; color: {text}; }} QScrollArea, QScrollArea > QWidget > QWidget {{ background: transparent; border: 0; }} CardWidget {{ background-color: {base}; border: 1px solid {border}; border-radius: 8px; }} QPlainTextEdit, QTextEdit, QLineEdit, QComboBox {{ background-color: {base}; color: {text}; border: 1px solid {border}; border-radius: 5px; selection-background-color: {accent}; }} QToolButton {{ color: {text}; }} """) def _apply_native_titlebar(self, is_dark): if os.name != "nt" or not hasattr(ctypes, "windll"): return try: hwnd = int(self.winId()) dwm = ctypes.windll.dwmapi dark = ctypes.c_int(1 if is_dark else 0) dwm.DwmSetWindowAttribute(hwnd, 20, ctypes.byref(dark), ctypes.sizeof(dark)) color = QColor("#202020" if is_dark else "#f4f6f8") colorref = ctypes.c_int(color.red() | (color.green() << 8) | (color.blue() << 16)) text = QColor("#f5f5f5" if is_dark else "#1f2328") textref = ctypes.c_int(text.red() | (text.green() << 8) | (text.blue() << 16)) dwm.DwmSetWindowAttribute(hwnd, 35, ctypes.byref(colorref), ctypes.sizeof(colorref)) dwm.DwmSetWindowAttribute(hwnd, 36, ctypes.byref(textref), ctypes.sizeof(textref)) except Exception: pass def _cur_display(self): try: return self.displays[self._disp_labels.index(self.cbo_display.currentText())] except (ValueError, IndexError, AttributeError): return None def _load_displays(self): previous = self._cur_label try: self.displays = display_win.get_displays() except Exception as exc: self.displays = [] self.notice("显示器枚举失败", str(exc), error=True) self._disp_labels = [f"{d['device']} {d['adapter']} [{d['mon_name']}]" for d in self.displays] self.cbo_display.blockSignals(True) self.cbo_display.clear() self.cbo_display.addItems(self._disp_labels) if self._disp_labels: self.cbo_display.setCurrentText(previous if previous in self._disp_labels else self._disp_labels[0]) self.cbo_display.blockSignals(False) if self._disp_labels: self._cur_label = self.cbo_display.currentText() self._on_select(self._cur_label) self.left_scroll.verticalScrollBar().setValue(0) self.right_scroll.verticalScrollBar().setValue(0) else: self.lbl_cur.setText("未找到活动显示器") self.txt_modes.clear(); self.txt_edid.clear(); self.txt_hex.clear() def _on_select(self, _value=None): d = self._cur_display() if d is None: return self._cur_label = self.cbo_display.currentText() self.lbl_cur.setText(f"当前输出: {d['cur_mode']}") keys, self._modes = display_win.enum_modes(d['device']) self.cbo_mode.clear(); self.cbo_mode.addItems(keys) if d['cur_mode'] in self._modes: self.cbo_mode.setCurrentText(d['cur_mode']) if not d['edid']: self.txt_edid.setPlainText("EDID 读取失败(注册表无 EDID 数据)") self.txt_modes.clear(); self.txt_hex.clear(); return parsed = edid_parse.parse_edid(d['edid']) self.txt_edid.setPlainText("\n".join(f"{k:<16}{v}" for k, v in edid_parse.edid_info_lines(parsed))) lines = [f"{t['hact']}x{t['vact']} @{t['refresh']:g}Hz {t['clk_khz'] / 1000:.2f}MHz {'DTD 首选' if i == 0 else 'DTD'}" for i, t in enumerate(parsed['dtds'])] lines += [f"{m} [CEA-VIC]" for m in parsed['cea_video']] lines += [f"{m} [已建立时序]" for m in parsed['est']] lines += [f"{m} [标准时序]" for m in parsed['std']] self.txt_modes.setPlainText("\n".join(lines)) self.txt_hex.setPlainText(edid_parse.hex_dump(d['edid'])) def _apply_mode(self): d = self._cur_display() if d is None or self.cbo_mode.currentText() not in self._modes: return device = d['device'] if device not in self._orig_modes: current = display_win.DEVMODEW(); current.dmSize = ctypes.sizeof(current) if display_win.user32.EnumDisplaySettingsW(device, display_win.ENUM_CURRENT_SETTINGS, ctypes.byref(current)): self._orig_modes[device] = current ok, result = display_win.apply_mode(device, self._modes[self.cbo_mode.currentText()]) if not ok: self.notice("切换失败", f"ChangeDisplaySettingsEx 返回码 {result}", error=True) return QTimer.singleShot(500, self._load_displays) def _restore_mode(self): d = self._cur_display() if d is None: return mode = self._orig_modes.pop(d['device'], None) if mode is None: self.notice("无可恢复", "该显示器没有记录切改前的模式") return display_win.apply_mode(d['device'], mode) QTimer.singleShot(500, self._load_displays) def _show_pattern(self, kind): d = self._cur_display() if d and d['rect']: close_qt_patterns(); PatternWindow(d['rect'], kind, self.cbo_area.currentText(), parent=self) def _show_solid(self, color): self._show_pattern(color) def _save_area(self, value): self.area = value config.save_slots(self.slots, value) def _show_custom(self, index): slot = self.slots[index] if not slot['file']: self.notice("未配置图片", f"请先配置“{slot['name']}”的图片") return path = os.path.join(config.BASE_DIR, slot['file']) d = self._cur_display() if not os.path.exists(path): self.notice("图片不存在", path, error=True); return if d and d['rect']: close_qt_patterns(); PatternWindow(d['rect'], "", self.cbo_area.currentText(), path, self) def _configure_slot(self, index): dialog = QDialog(self); dialog.setWindowTitle(f"配置自定义栏位 {index + 1}") form = QFormLayout(dialog) name = LineEdit(dialog); name.setText(self.slots[index]['name']) file_label = BodyLabel(self.slots[index]['file'] or "(未设置)", dialog) form.addRow("按钮名称", name); form.addRow("图片文件", file_label) buttons = QHBoxLayout(); choose = PushButton("选择图片...", dialog); clear = PushButton("清除图片", dialog); save = PrimaryPushButton("保存", dialog); cancel = PushButton("取消", dialog) buttons.addWidget(choose); buttons.addWidget(clear); buttons.addStretch(1); buttons.addWidget(save); buttons.addWidget(cancel); form.addRow(buttons) selected = [self.slots[index]['file']] def choose_file(): path, _ = QFileDialog.getOpenFileName(dialog, "选择图片", config.BASE_DIR, f"图片文件 ({' '.join('*' + e for e in config.IMG_EXTS)})") if path: try: selected[0] = config.copy_image_into_script_dir(path); file_label.setText(selected[0]) except OSError as exc: self.notice("复制图片失败", str(exc), error=True) choose.clicked.connect(choose_file); clear.clicked.connect(lambda: (selected.__setitem__(0, None), file_label.setText("(未设置)"))) cancel.clicked.connect(dialog.reject) def save_slot(): self.slots[index] = {'name': name.text().strip() or f'自定义{index + 1}', 'file': selected[0]} config.save_slots(self.slots, self.cbo_area.currentText()); self.slot_buttons[index].setText(self.slots[index]['name']); dialog.accept() save.clicked.connect(save_slot); dialog.exec() def _show_about(self): QMessageBox.about(self, f"关于 {APP_NAME}", f"{APP_NAME} v{APP_VERSION}\n\nEDID / 测试图案 / RS485 光机控制\nPySide6-Fluent-Widgets UI\n\n{REPO_URL}") def _show_edid_fields(self): d = self._cur_display() if d is None or not d['edid']: self.notice("无 EDID", "该显示器没有可解析的 EDID 数据"); return dialog = QDialog(self); dialog.setWindowTitle("EDID 逐字节解析"); dialog.resize(1120, 700) layout = QVBoxLayout(dialog); text = self._text(); text.setLineWrapMode(QPlainTextEdit.WidgetWidth) lines = [] for off, hx, meaning, value, note in edid_parse.edid_field_rows(d['edid']): lines.append(f"{off} {meaning} {value}") if hx: lines.append(f" 原始: {hx}") if note: lines.append(f" {note}") text.setPlainText("\n".join(lines)); layout.addWidget(text); layout.addWidget(self._button("关闭", dialog.accept)); dialog.exec() # ---------------- RS485 ---------------- def _dlpc_on_frame(self, frame): if self._pd_seq: self._dlpc_log_q.put(('pdframe', frame)) elif self._pwr_seq: self._dlpc_log_q.put(('pwrframe', frame)) def _dlpc_on_event(self, line): match = re.search(r'← okTL:(-?\d+)', line) if match and self._tl_order: which = self._tl_order.pop(0); self._dlpc_log_q.put(('temp', f"{which}: {int(match.group(1)) / 10:.1f}°C")) match = re.search(r'← okR(\d+)G(\d+)B(\d+)', line) if match: self._dlpc_log_q.put(('current', f"电流 R:{match.group(1)} G:{match.group(2)} B:{match.group(3)}")) self._dlpc_log_q.put(('event', line)) def _dlpc_on_status(self, message): self._dlpc_log_q.put(('status', message)) def _query_current(self): self._dlpc_send('M732') def _query_dmd(self): self._tl_order.append('DMD'); self._dlpc_send('M201') def _query_lamp(self): self._tl_order.append('LAMP'); self._dlpc_send('M202') def _poll_dlpc(self): try: while True: kind, value = self._dlpc_log_q.get_nowait() if kind == 'status': self.lbl_dlpc_st.setText(value) if value.startswith(('已断开', '串口丢失')): self._serial_reset_state() elif kind == 'temp': (self.var_dmd if value.startswith('DMD') else self.var_lamp).setText(value) elif kind == 'current': self.var_current.setText(value) elif kind == 'pdframe': self._pd_step_done(value.type_code == 0xF0, value) elif kind == 'pwrframe': self._pwr_step_done(value) else: self._dlpc_log(value) except queue.Empty: pass connected = self.dlpc.is_connected() if connected != self._dlpc_connected: self._dlpc_connected = connected; self.btn_dlpc.setText("断开" if connected else "连接") def _dlpc_log(self, line): self.txt_dlpc.appendPlainText(time.strftime('%H:%M:%S ') + line) if self.txt_dlpc.blockCount() > 500: cursor = self.txt_dlpc.textCursor(); cursor.movePosition(cursor.Start); cursor.select(cursor.BlockUnderCursor); cursor.removeSelectedText(); cursor.deleteChar() self.txt_dlpc.verticalScrollBar().setValue(self.txt_dlpc.verticalScrollBar().maximum()) def _dlpc_clear(self): self.txt_dlpc.clear() def _dlpc_refresh(self): self._dlpc_ports = dlpc_rs485.DLPC485.list_ports_info(); self.cbo_dlpc.clear(); self.cbo_dlpc.addItems([label for _d, label in self._dlpc_ports]) if self._dlpc_ports: self._dlpc_pick(config.SERIAL_CFG['port'] or self._dlpc_ports[0][0]) def _dlpc_pick(self, device): for dev, label in self._dlpc_ports: if dev == device: self.cbo_dlpc.setCurrentText(label); return def _dlpc_selected_port(self): label = self.cbo_dlpc.currentText() for device, shown in self._dlpc_ports: if shown == label: return device return label.strip() def _dlpc_toggle(self): if self.dlpc.is_connected(): self.dlpc.close(); self._serial_reset_state(); return port = self._dlpc_selected_port() try: baud, bytesize, stopbits = int(self.cbo_baud.currentText()), int(self.cbo_databits.currentText()), int(self.cbo_stopbits.currentText()) except ValueError: self.lbl_dlpc_st.setText("串口参数非法"); return parity = self.cbo_parity.currentText()[:1] or 'N'; config.SERIAL_CFG.update(port=port, baud=baud, bytesize=bytesize, parity=parity, stopbits=stopbits); config.save_slots(self.slots, self.cbo_area.currentText()); self.dlpc.open(port, baud, bytesize=bytesize, parity=parity, stopbits=stopbits) def _serial_reset_state(self): self._pd_seq.clear(); self._pwr_seq.clear(); self._tl_order.clear() for timer in (self._pd_timer, self._pwr_timer): if timer: timer.stop() self._pd_timer = self._pwr_timer = None def _dlpc_send(self, command): if not self.dlpc.is_connected(): self.lbl_dlpc_st.setText('未连接串口'); return if self._pd_seq or self._pwr_seq: self._dlpc_log(f'序列进行中, 命令已忽略: {command}'); return self.dlpc.send(command) def _dlpc_send_preset(self): label = self.cbo_dlpc_cmd.currentText() for name, command in self._dlpc_presets: if label.startswith(name + ' '): self._dlpc_send(command); return def _dlpc_send_custom(self): if self.var_custom_cmd.text().strip(): self._dlpc_send(self.var_custom_cmd.text().strip()) def _pd_selftest(self): if not self.dlpc.is_connected(): self.lbl_dlpc_st.setText('未连接串口'); return if self._pd_seq: return self._pd_seq = [('M301', 'pd'), ('M302', 'fpdo_ma'), ('M303', 'meas_mv')]; self._pd_values = {}; self._pd_chip_reset(); self._dlpc_log('电源自检: 开始'); self._pd_step_next() def _pd_chip_reset(self): self.chip_pd.setText('PD'); self.chip_pd.setStyleSheet(f'background: {CHIP_IDLE}; color: white; border-radius: 6px; padding: 4px 8px;'); self.chip_fpdo.setText('FPDO: —'); self.chip_fpdo.setStyleSheet(f'background: {CHIP_IDLE}; color: white; border-radius: 6px; padding: 4px 8px;') def _pd_step_next(self): if not self._pd_seq: self._pd_fpdo_verdict(); self._dlpc_log('电源自检: 完成'); return self.dlpc.send(self._pd_seq[0][0]); self._pd_timer = QTimer(self); self._pd_timer.setSingleShot(True); self._pd_timer.timeout.connect(lambda: self._pd_step_done(False, None)); self._pd_timer.start(3000) def _pd_step_done(self, ok, frame): if not self._pd_seq: return if self._pd_timer: self._pd_timer.stop(); self._pd_timer = None _command, kind = self._pd_seq.pop(0); raw = dlpc_rs485.payload_bytes(frame) if frame is not None else b'' if ok and kind == 'pd': proto = raw.decode('ascii', 'replace').strip() good = proto in ('PD', 'EPR') self.chip_pd.setText(proto or 'PD') self.chip_pd.setStyleSheet( f'background: {CHIP_OK if good else CHIP_ERR}; color: white; ' 'border-radius: 6px; padding: 4px 8px;') elif ok and kind == 'fpdo_ma' and len(raw) == 2: self._pd_values['ma'] = dlpc_rs485.payload_u16_be(raw) elif ok and kind == 'meas_mv' and len(raw) == 2: self._pd_values['mv'] = dlpc_rs485.payload_u16_be(raw) if ok: self._pd_step_next() else: self._pd_seq.clear(); self._pd_chip_error(kind); self._dlpc_log('电源自检: 应答错误/超时, 序列中止') def _pd_chip_error(self, kind): widget = self.chip_pd if kind == 'pd' else self.chip_fpdo if kind == 'pd': widget.setText('ERR') else: widget.setText('FPDO: ERR') widget.setStyleSheet(f'background: {CHIP_ERR}; color: white; border-radius: 6px; padding: 4px 8px;') def _pd_fpdo_verdict(self): mv, ma = self._pd_values.get('mv'), self._pd_values.get('ma') if mv is None or ma is None: self._pd_chip_error('fpdo_ma'); return gear = next((g for g in (5, 9, 12, 15, 20, 28) if abs(mv - g * 1000) <= g * 50), None); text = 'FPDO: ERR' if gear is None else f'FPDO: {gear}V@{ma / 1000:g}A'; self.chip_fpdo.setText(text); good = gear == 12 and ma >= 3000; self.chip_fpdo.setStyleSheet(f'background: {CHIP_OK if good else CHIP_ERR}; color: white; border-radius: 6px; padding: 4px 8px;') def _pwr_query(self): if not self.dlpc.is_connected(): self.lbl_dlpc_st.setText('未连接串口'); return if self._pwr_seq: return self._pwr_seq = [('M303', 'mv'), ('M304', 'ma'), ('M305', 'mw')]; self._pwr_values = {}; self._dlpc_log('功率检测: 开始'); self._pwr_step_next() def _pwr_step_next(self): if not self._pwr_seq: self._pwr_update(self._pwr_values.get('mv'), self._pwr_values.get('ma'), self._pwr_values.get('mw')); self._dlpc_log('功率检测: 完成'); return self.dlpc.send(self._pwr_seq[0][0]); self._pwr_timer = QTimer(self); self._pwr_timer.setSingleShot(True); self._pwr_timer.timeout.connect(lambda: self._pwr_step_done(None)); self._pwr_timer.start(3000) def _pwr_step_done(self, frame): if not self._pwr_seq: return if self._pwr_timer: self._pwr_timer.stop(); self._pwr_timer = None _command, kind = self._pwr_seq.pop(0); raw = dlpc_rs485.payload_bytes(frame) if frame is not None else b''; want = {'mv': 2, 'ma': 2, 'mw': 4}[kind] if frame is None or frame.type_code != 0xF0 or len(raw) != want: self._pwr_seq.clear(); self.box_pwr.setText('电压: ERR 电流: — 功率: —'); return self._pwr_values[kind] = {'mv': dlpc_rs485.payload_u16_be, 'ma': dlpc_rs485.payload_s16_be, 'mw': dlpc_rs485.payload_u32_be}[kind](raw); self._pwr_step_next() def _pwr_update(self, mv, ma, mw): voltage = f'{mv / 1000:.2f} V' if mv is not None else '—' current = f'{ma} mA' if ma is not None else '—' power = f'{mw / 1000:.2f} W' if mw is not None else '—' self.box_pwr.setText(f'电压: {voltage} 电流: {current} 功率: {power}') def _dlpc_send_light(self): self._sync_slider(self.sld_light, self.ent_light, 54, 800); self._dlpc_send(f'M731S{self.sld_light.value()}') def _dlpc_send_timer(self): self._sync_slider(self.sld_timer, self.ent_timer, 0, 32700); self._dlpc_send('M730S0' if self.sld_timer.value() <= 0 else f'M730S0T{self.sld_timer.value()}') def _dlpc_send_tec(self): self._sync_slider(self.sld_tec, self.ent_tec, 200, 350); self._dlpc_send(f'M8S{self.sld_tec.value()}') def notice(self, title, text, error=False): box = QMessageBox(self); box.setWindowTitle(title); box.setText(text); box.setIcon(QMessageBox.Critical if error else QMessageBox.Information); box.exec() def closeEvent(self, event): close_qt_patterns() self._serial_reset_state() try: self.dlpc.close() except Exception: pass super().closeEvent(event) def run_gui(auto_close=0): if not _single_instance(): app = QApplication.instance() or QApplication(sys.argv) QMessageBox.information(None, "DLPilot 已在运行", "检测到另一个 DLPilot 实例正在运行。") return app = QApplication.instance() or QApplication(sys.argv) app.setApplicationName(APP_NAME) setTheme(Theme.AUTO) window = App(auto_close) window.show() app.exec()