Files
FilmForge/GUI/app_fluent.py
T
hulk 8035f9b7d3 GUI v0.2.9: fix chronic-lag root cause (log trim crash on PySide6 6.11) + 5 hardening fixes
- log trim used instance-level enum access (removed in PySide6 6.11), crashed
2026-10-12 16:14:53 +08:00

1380 lines
58 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""FilmForge Fluent UI — 热压模具温控闭环监视台。
协议与串口模块(filmforge_rs485)保持不变, 本文件只做呈现层:
PySide6-Fluent-Widgets 卡片式双栏仪表盘 + 自绘滚动温度曲线(无第三方图表依赖)。
闭环监视 = 实测温度曲线 + 目标温度阶梯线同图 + 加热/上报/传感器状态联动;
温度监控走固件 M2 周期上报帧(M2S<ms> 开启), M1 仅作手动单点查询。
"""
import ctypes
import math
import os
import queue
import sys
import time
from collections import deque
# 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 QPointF, QRectF, Qt, QTimer
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPainterPath, QPen, QPalette
from PySide6.QtWidgets import (
QApplication, QHBoxLayout, QLabel, QMainWindow, QMessageBox, QPlainTextEdit,
QScrollArea, QSplitter, QVBoxLayout, QWidget,
)
from qfluentwidgets import (
BodyLabel, CardWidget, ComboBox, LineEdit, PrimaryPushButton, PushButton,
Slider, StrongBodyLabel, Theme, setCustomStyleSheet, setTheme,
)
import config
import filmforge_rs485 as ff
APP_NAME = "FilmForge"
APP_VERSION = "0.2.9"
REPO_URL = "https://gitea.hulk.wang/hulk/FilmForge"
MONO = QFont("Consolas", 10)
CHIP_IDLE = "#666666"
CHIP_OK = "#2FA574"
CHIP_ERR = "#D9534F"
CHIP_WARN = "#E8833A"
PENDING_TIMEOUT = 1.2 # 应答超时(秒): 协议建议 300ms 重发, 监视台不自动重发只提示
RESPONSE_FRESH = 0.5 # 应答新鲜度: 该窗口内的温度帧优先按“查询应答”派发
MAX_FAULTS = 1000 # 故障标记内存上限(条): NTC 抖动场景防无限增长
CHART_COLORS_DARK = dict(bg="#242424", grid="#3a3a3a", text="#c9c9c9",
measured="#00c8d7", target=CHIP_WARN, fault=CHIP_ERR)
CHART_COLORS_LIGHT = dict(bg="#ffffff", grid="#e3e8ee", text="#5a6572",
measured="#0f6cbd", target="#d96f1a", fault=CHIP_ERR)
X_TICK_STEPS = (1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600)
DANGER_QSS = ("PushButton, PushButton:hover, PushButton:pressed, PushButton:disabled "
"{ background-color: #D9534F; color: white; border: none; }")
class TemperatureChart(QWidget):
"""自绘滚动温度曲线(QWidget + QPainter, 不引第三方图表库)。
数据由 App 持有, 经 set_source() 传入引用; 视窗/冻结/配色独立设置。
曲线语义: 实测=折线(NTC 故障处 NaN 断开+底部红标), 目标=阶梯横线(橙虚线)。
"""
def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumHeight(300)
self._samples_t = []
self._samples_v = []
self._target_hist = []
self._faults = []
self._window_sec = config.DEFAULT_WINDOW_SEC
self._frozen_at = None # None=实时跟随; 否则冻结该时刻
self._colors = dict(CHART_COLORS_DARK)
self._has_data = False
# ---- 数据/状态注入 ----
def set_source(self, samples_t, samples_v, target_hist, faults):
self._samples_t, self._samples_v = samples_t, samples_v
self._target_hist, self._faults = target_hist, faults
self._has_data = bool(samples_t) or bool(faults)
self.update()
def set_window(self, sec):
self._window_sec = int(sec)
self.update()
def set_frozen(self, at):
self._frozen_at = at
self.update()
def set_colors(self, colors):
self._colors = dict(colors)
self.update()
# ---- 视窗 ----
def _view(self):
t1 = self._frozen_at if self._frozen_at is not None else time.time()
if self._window_sec > 0:
return t1 - self._window_sec, t1
ts = self._samples_t
t0 = min(ts[0], self._target_hist[0][0]) if ts and self._target_hist \
else (ts[0] if ts else t1 - 60)
return min(t0, t1 - 30), t1
def _visible(self, t0, t1):
"""视窗内样本 (t 列表, v 列表); 超过 4000 点按步进抽稀(保 NaN 断口)"""
ts, vs = self._samples_t, self._samples_v
if not ts:
return [], []
lo, hi = 0, len(ts) # 左边界二分(留一点前文保证连线)
while lo < hi:
m = (lo + hi) // 2
if ts[m] < t0:
lo = m + 1
else:
hi = m
lo = max(lo - 1, 0)
end, hi = lo, len(ts) # 右边界二分: 暂停(冻结)后不切冻结尾后新样本
while end < hi:
m = (end + hi) // 2
if ts[m] <= t1:
end = m + 1
else:
hi = m
t_out, v_out = ts[lo:end], vs[lo:end]
if len(t_out) > 4000:
stride = math.ceil(len(t_out) / 4000)
gt, gv = [], []
for i in range(0, len(t_out), stride):
chunk = v_out[i:i + stride]
gt.append(t_out[i])
gv.append(float("nan") if any(v != v for v in chunk) else chunk[0])
t_out, v_out = gt, gv
return t_out, v_out
def _target_points(self, t0, t1):
"""目标阶梯线: [(t,v)...] 每段从自身时刻起垂直落变并水平延伸至下一变更,
最后一段延伸到右缘。旧实现对"最后一次变更"只画终点, 会从旧值斜拉一条
直线到右缘(实测 57→41 被画成 2 分钟斜坡), 已改为纯阶梯。"""
hist = self._target_hist
if not hist:
return []
pts = []
for i, (ti, vi) in enumerate(hist):
tn = hist[i + 1][0] if i + 1 < len(hist) else t1
if tn < t0 or ti > t1:
continue
start, end = max(ti, t0), min(tn, t1)
if not pts:
pts.append((start, vi))
else:
if pts[-1][0] != start:
pts.append((start, pts[-1][1])) # 空窗: 先延续旧值(防御)
pts.append((start, vi)) # 与上一段终点同 x → 垂直落变
if end > start:
pts.append((end, vi))
if pts and pts[-1][0] < t1:
pts.append((t1, pts[-1][1]))
return pts
# ---- 绘制 ----
def paintEvent(self, _event):
p = QPainter(self)
c = self._colors
p.fillRect(self.rect(), QColor(c["bg"]))
area = QRectF(self.rect()).adjusted(52, 10, -12, -26)
t0, t1 = self._view()
vis_t, vis_v = self._visible(t0, t1)
tgt = self._target_points(t0, t1)
ymin, ymax = self._y_range(vis_v, tgt)
sy = self._scale_y(area, ymin, ymax)
sx = self._scale_x(area, t0, t1)
self._draw_grid(p, area, t0, t1, ymin, ymax, sx, sy)
self._draw_target(p, tgt, sx, sy)
self._draw_measured(p, vis_t, vis_v, sx, sy)
self._draw_faults(p, area, t0, t1)
self._draw_legend(p)
if not self._has_data:
p.setPen(QColor(c["text"]))
p.drawText(self.rect(), Qt.AlignCenter,
"暂无数据 — 连接设备后开启周期上报(M2S<ms>)即可实时推送; 单点查询用 M1")
@staticmethod
def _y_range(vis_v, tgt_pts):
vals = [v for v in vis_v if v == v] + [v for _t, v in tgt_pts]
if not vals:
return -5.0, 55.0
lo, hi = min(vals), max(vals)
if hi - lo < 2.0:
mid = (hi + lo) / 2
lo, hi = mid - 1.0, mid + 1.0
pad = (hi - lo) * 0.1
return lo - pad, hi + pad
@staticmethod
def _nice_step(span, ticks):
if span <= 0:
return 1.0
raw = span / max(ticks, 1)
mag = 10 ** math.floor(math.log10(raw)) if raw > 0 else 1.0
for m in (1, 2, 2.5, 5, 10):
if raw <= m * mag:
return m * mag
return 10 * mag
@staticmethod
def _scale_y(area, ymin, ymax):
h = area.height()
def sy(v):
return area.bottom() - (v - ymin) / (ymax - ymin) * h
return sy
@staticmethod
def _scale_x(area, t0, t1):
w = area.width()
def sx(t):
return area.left() + (t - t0) / max(t1 - t0, 1e-6) * w
return sx
def _draw_grid(self, p, area, t0, t1, ymin, ymax, sx, sy):
c = self._colors
grid_pen = QPen(QColor(c["grid"]), 1)
text_pen = QColor(c["text"])
font = self.font()
font.setPointSizeF(8.0)
p.setFont(font)
ystep = self._nice_step(ymax - ymin, 5)
v = math.ceil(ymin / ystep) * ystep
while v <= ymax:
y = sy(v)
p.setPen(grid_pen)
p.drawLine(QPointF(area.left(), y), QPointF(area.right(), y))
p.setPen(text_pen)
p.drawText(QRectF(0, y - 8, area.left() - 6, 16),
Qt.AlignRight | Qt.AlignVCenter, f"{v:g}")
v += ystep
span = t1 - t0
xstep = next((s for s in X_TICK_STEPS if span / s <= 8), 3600)
t = math.ceil((t0 + 0.001) / xstep) * xstep
while t <= t1:
x = sx(t)
p.setPen(grid_pen)
p.drawLine(QPointF(x, area.top()), QPointF(x, area.bottom()))
p.setPen(text_pen)
p.drawText(QRectF(x - 46, area.bottom() + 4, 92, 16),
Qt.AlignHCenter | Qt.AlignVCenter,
time.strftime("%H:%M:%S", time.localtime(t)))
t += xstep
def _draw_target(self, p, tgt, sx, sy):
if not tgt:
return
p.setPen(QPen(QColor(self._colors["target"]), 1.4, Qt.DashLine))
path = QPainterPath()
for i, (t, v) in enumerate(tgt):
if i == 0:
path.moveTo(sx(t), sy(v))
else:
path.lineTo(sx(t), sy(v))
p.drawPath(path)
def _draw_measured(self, p, ts, vs, sx, sy):
if not ts:
return
p.setRenderHint(QPainter.Antialiasing, True)
pen = QPen(QColor(self._colors["measured"]), 1.6)
pen.setCapStyle(Qt.FlatCap)
p.setPen(pen)
path = QPainterPath()
drawing = False
for t, v in zip(ts, vs):
if v != v: # NaN: NTC 故障断口
drawing = False
continue
x, y = sx(t), sy(v)
if not drawing:
path.moveTo(x, y)
drawing = True
else:
path.lineTo(x, y)
p.drawPath(path)
last_t = last_v = None # 当前值标记: 视窗内最后一个有效样本
for t, v in zip(reversed(ts), reversed(vs)):
if v == v:
last_t, last_v = t, v
break
if last_t is not None:
x, y = sx(last_t), sy(last_v)
p.setPen(Qt.NoPen)
p.setBrush(QColor(self._colors["measured"]))
p.drawEllipse(QPointF(x, y), 3.2, 3.2)
p.setPen(QColor(self._colors["measured"]))
font = self.font()
font.setPointSizeF(8.5)
font.setBold(True)
p.setFont(font)
p.drawText(QPointF(min(x + 6, self.width() - 54), max(y - 6, 14)),
f"{last_v:.1f}℃")
def _draw_faults(self, p, area, t0, t1):
flt = [(t, s) for t, s in self._faults if t0 <= t <= t1]
if not flt:
return
span = max(t1 - t0, 1e-6)
p.setPen(QColor(self._colors["fault"]))
font = self.font()
font.setPointSizeF(7.5)
p.setFont(font)
labeled = 0
last_x = -1e9
for t, s in flt:
x = area.left() + (t - t0) / span * area.width()
p.drawLine(QPointF(x, area.bottom() - 8), QPointF(x, area.bottom()))
if labeled < 12 and x - last_x > 52:
p.drawText(QPointF(x + 3, area.bottom() - 10), s)
last_x = x
labeled += 1
def _draw_legend(self, p):
c = self._colors
font = self.font()
font.setPointSizeF(8.5)
p.setFont(font)
x = self.width() - 150
y = 16
for color, label in ((c["measured"], "实测"), (c["target"], "目标")):
p.setPen(Qt.NoPen)
p.setBrush(QColor(color))
p.drawRect(QRectF(x, y - 7, 14, 3))
p.setPen(QColor(c["text"]))
p.drawText(QPointF(x + 18, y), label)
x += 66
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} — 热压模具温控监视台")
icon = _icon_path() # 标题栏图标(run_gui 另设 app 级 + 任务栏标识)
if os.path.exists(icon):
self.setWindowIcon(QIcon(icon))
self.resize(1440, 920)
self.setMinimumSize(1150, 720)
config.load_settings()
# ---- 监视数据(主线程持有; 串口线程只经队列投递) ----
self.samples_t = [] # time.time() 墙钟, 串口线程读取点打戳
self.samples_v = [] # ℃, NaN = NTC 故障断口
self.target_hist = [] # [(t, 目标℃)]
self.faults = [] # [(t, 'OPEN'|'SHRT')]
self.sensor_state = None # None / 'OK' / 'OPEN' / 'SHRT'
self.heater_state = None # None / 'ON' / 'OFF'
self.report_state = None # None / ('ON', ms) / ('OFF', None)
self.firmware = None
# 应答 FIFO: 半双工上固件严格按收到顺序应答, 连接序列/连发场景下
# 多个应答可能同时在途, 单 pending 槽会被乱序击穿(实测踩坑)
self._pending = deque() # [{'cmd','kind','t','apply','then'}]
self._frozen_at = None
self._want = False # 连接按钮意图(点击即翻转, 不等线程实际开/关)
self._want_since = None # 意图=连接但实际未建立的起始时刻(丢失判定)
self._last_set = None # 最近一条设定命令(t/cmd/apply): 晚到 ok 补偿
self._resume_report_ms = None # M4 查询前暂停的上报间隔(查询完自动恢复)
self._seq = [] # 连接同步序列剩余步骤
self._last_chart_repaint = 0.0
self._ports = []
self._rx_q = queue.Queue()
# ---- CSV 记录 ----
self._csv = None
self._csv_path = ""
self._csv_t0 = 0.0
self._csv_count = 0
self.link = ff.FF485(self._on_event, self._on_status, on_frame=self._on_frame)
self._connected = False
self._build()
self._apply_settings()
self._timer = QTimer(self)
self._timer.timeout.connect(self._tick)
self._timer.start(100)
if ff.HAS_SERIAL:
self._refresh_ports(silent=True)
else:
self.lbl_st.setText("未安装 pyserial: py -m pip install pyserial")
if auto_close > 0:
QTimer.singleShot(auto_close * 1000, self.close)
# ================= 界面构建 =================
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, trailing=None):
card = CardWidget(self)
layout = QVBoxLayout(card)
layout.setContentsMargins(14, 12, 14, 14)
layout.setSpacing(8)
row = QHBoxLayout()
row.addWidget(StrongBodyLabel(title, card))
row.addStretch(1)
if trailing is not None:
row.addWidget(trailing)
layout.addLayout(row)
return card, layout
def _chip(self, text):
lbl = QLabel(text, self)
lbl.setMinimumHeight(30)
lbl.setAlignment(Qt.AlignCenter | Qt.AlignVCenter)
lbl.setStyleSheet(f"background: {CHIP_IDLE}; color: white; "
"border-radius: 6px; padding: 4px 8px;")
return lbl
@staticmethod
def _set_chip(lbl, text, color):
lbl.setText(text)
lbl.setStyleSheet(f"background: {color}; color: white; "
"border-radius: 6px; padding: 4px 8px;")
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("FilmForge", root)
# 标题字号走 QFont: FluentLabelBase 切主题时会重写自定义 qss 槽(DLPilot §3.12)
tf = title.font()
tf.setPixelSize(24)
tf.setBold(True)
title.setFont(tf)
header.addWidget(title)
header.addWidget(BodyLabel("热压模具温控 · RS485 闭环监视", root))
header.addStretch(1)
self.cbo_theme = ComboBox(root)
self.cbo_theme.addItems(["Dark", "Light", "System"])
self.cbo_theme.currentTextChanged.connect(self._set_theme)
header.addWidget(self.cbo_theme)
header.addWidget(self._button("关于", self._about))
outer.addLayout(header)
# 双列放进同一滚动容器: 两列高度锁定一致——不足一屏时各自拉伸到底边,
# 超出一屏时整页一起滚动; 各列独立滚动会让底边在窗口化下错位(实测)
split = QSplitter(Qt.Horizontal)
split.setChildrenCollapsible(False)
split.addWidget(self._column_host(self._build_left))
split.addWidget(self._column_host(self._build_right))
split.setStretchFactor(0, 3)
split.setStretchFactor(1, 2)
page = QWidget()
page_lay = QHBoxLayout(page)
page_lay.setContentsMargins(0, 0, 0, 0)
page_lay.addWidget(split)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
scroll.setWidget(page)
outer.addWidget(scroll, 1)
self.setCentralWidget(root)
def _column_host(self, builder):
"""构建一列的宿主 QWidget(不再各自包 QScrollArea, 整页统一滚动)"""
host = QWidget()
layout = QVBoxLayout(host)
layout.setContentsMargins(2, 2, 8, 2)
layout.setSpacing(10)
builder(layout)
layout.addStretch(1)
return host
# ---------------- 左列: 串口 + 监视 ----------------
def _build_serial_card(self, layout):
"""串口连接卡片(置于左列顶部): 端口/波特率选择 + 连接开关 + 状态行"""
if ff.HAS_SERIAL:
card, body = self._card("串口 · RS485 → FilmForge 温控板")
row = QHBoxLayout()
self.cbo_port = ComboBox(card)
row.addWidget(self.cbo_port, 1)
row.addWidget(self._button("刷新", self._refresh_ports))
self.btn_conn = self._button("连接", self._toggle_connect, True)
row.addWidget(self.btn_conn)
body.addLayout(row)
params = QHBoxLayout()
self.cbo_baud = ComboBox(card)
self.cbo_baud.addItems(["115200", "57600", "38400", "9600"])
self.cbo_baud.setCurrentText(str(config.SERIAL_CFG["baud"]))
params.addWidget(BodyLabel("波特率", card))
params.addWidget(self.cbo_baud)
self.lbl_st = BodyLabel("未连接", card)
self.lbl_st.setWordWrap(True) # 长错误串不得撑大参数行(DLPilot 教训)
params.addWidget(self.lbl_st, 1)
body.addLayout(params)
layout.addWidget(card)
else:
self.cbo_port = None
self.btn_conn = None
self.lbl_st = BodyLabel("未安装 pyserial, 串口功能不可用。", self)
self.lbl_st.setWordWrap(True)
card, body = self._card("串口不可用")
body.addWidget(self.lbl_st)
layout.addWidget(card)
def _build_left(self, layout):
self._build_serial_card(layout)
win_combo = ComboBox(self)
for name, _sec in config.WINDOW_OPTIONS:
win_combo.addItem(name)
win_combo.setCurrentIndex(1) # 先设默认再连信号, 避免触发时 chart 未建
win_combo.currentIndexChanged.connect(self._on_window)
self.cbo_window = win_combo
row = QHBoxLayout()
self.btn_pause = self._button("暂停", self._toggle_pause)
self.btn_clear = self._button("清空", self._clear_plot)
row.addWidget(self.btn_pause)
row.addWidget(self.btn_clear)
card, body = self._card("实时温度曲线", trailing=win_combo)
body.addLayout(row)
self.chart = TemperatureChart(card)
self.chart.set_source(self.samples_t, self.samples_v, self.target_hist, self.faults)
body.addWidget(self.chart, 1)
layout.addWidget(card, 2)
card, body = self._card("实时读数")
big_row = QHBoxLayout()
self.lbl_temp = QLabel("—.— ℃", self)
bf = self.lbl_temp.font()
bf.setPixelSize(34)
bf.setBold(True)
self.lbl_temp.setFont(bf)
big_row.addWidget(self.lbl_temp)
big_row.addStretch(1)
chip_grid = QVBoxLayout()
top = QHBoxLayout()
bottom = QHBoxLayout()
self.chip_sensor = self._chip("NTC: —")
self.chip_heater = self._chip("加热: —")
self.chip_report = self._chip("上报: —")
self.chip_target = self._chip("目标: —")
top.addWidget(self.chip_sensor)
top.addWidget(self.chip_heater, 1)
bottom.addWidget(self.chip_report)
bottom.addWidget(self.chip_target, 1)
chip_grid.addLayout(top)
chip_grid.addLayout(bottom)
big_row.addLayout(chip_grid)
body.addLayout(big_row)
self.lbl_stats = BodyLabel("窗口统计: —", self)
body.addWidget(self.lbl_stats)
layout.addWidget(card)
card, body = self._card("数据记录 · CSV")
row = QHBoxLayout()
self.btn_csv = self._button("开始记录", self._toggle_csv)
self.btn_opendir = self._button("打开目录", self._open_log_dir)
row.addWidget(self.btn_csv)
row.addWidget(self.btn_opendir)
row.addStretch(1)
body.addLayout(row)
self.lbl_csv = BodyLabel("未记录。开始后逐行写入 logs/ 下 CSV(时间/温度/目标/加热/传感器)。", self)
self.lbl_csv.setWordWrap(True)
body.addWidget(self.lbl_csv)
layout.addWidget(card)
# ---------------- 右列: 控制 ----------------
def _build_right(self, layout):
card, body = self._card("温度控制 · 目标 / 加热(PID 闭环)")
row = QHBoxLayout()
self.ent_target = LineEdit(card)
self.ent_target.setPlaceholderText("0~200 整数℃")
self.ent_target.setFixedWidth(110)
self.sld_target = Slider(Qt.Horizontal, card)
self.sld_target.setRange(0, 120) # 工况温度 <=120℃; 协议上限 200 仍可手输
self.sld_target.setValue(120)
self.sld_target.valueChanged.connect(lambda v: self.ent_target.setText(str(v)))
row.addWidget(BodyLabel("目标温度", card))
row.addWidget(self.ent_target)
row.addWidget(self._button("查询", self._query_target))
row.addWidget(self._button("设定", self._send_target, True))
body.addLayout(row)
body.addWidget(self.sld_target)
row = QHBoxLayout()
row.addWidget(self._button("查加热 M3", lambda: self._query("M3", "heater")))
row.addWidget(self._button("开启加热", self._heater_on))
row.addWidget(self._button("关闭加热",
lambda: self._send_set("M3S0", "heater", "OFF")))
row.addStretch(1)
body.addLayout(row)
body.addWidget(self._note(card, "开启加热后固件按目标温度 PID 闭环真实控温(500ms 周期,"
"慢 PWM 执行); NTC 故障或 ≥150℃ 过温时固件自动断电保护,"
"上电默认关闭。当前占空比无查询命令, 可从温度曲线间接观察。"))
layout.addWidget(card)
card, body = self._card("温度监控 · 周期上报 M2S<ms>")
row = QHBoxLayout()
self.ent_report = LineEdit(card)
self.ent_report.setPlaceholderText("100~60000, 10ms 步进")
self.ent_report.setFixedWidth(150)
self.ent_report.setText(str(config.REPORT_MS))
row.addWidget(BodyLabel("间隔 ms", card))
row.addWidget(self.ent_report)
row.addWidget(self._button("开启上报", self._report_on, True))
row.addWidget(self._button("停止",
lambda: self._send_set("M2S0", "report", ("OFF", None))))
row.addWidget(self._button("查状态", lambda: self._query("M2", "report")))
body.addLayout(row)
body.addWidget(self._note(card, "温度监控统一走 M2: 开启上报后固件按设定周期主动推送"
"温度帧入曲线; 停止后曲线暂停, 需要单点时用命令区"
" 查温度 M1(两条路读同一全局最新温度)。"))
layout.addWidget(card)
card, body = self._card("命令 · 预设 / 自定义")
row = QHBoxLayout()
row.addWidget(self._button("查温度 M1", lambda: self._query("M1", "temp")))
row.addWidget(self._button("查版本", lambda: self._query("M999", "version")))
row.addStretch(1)
self.lbl_ver = BodyLabel("固件: —", card)
row.addWidget(self.lbl_ver)
body.addLayout(row)
row = QHBoxLayout()
self.cbo_preset = ComboBox(card)
self.cbo_preset.addItems([f"{n} {c}" for n, c, _ in ff.PRESETS])
row.addWidget(BodyLabel("预设:", card))
row.addWidget(self.cbo_preset, 1)
row.addWidget(self._button("发送", self._send_preset, True))
body.addLayout(row)
row = QHBoxLayout()
self.ent_custom = LineEdit(card)
self.ent_custom.setPlaceholderText("自定义命令, 如 M2S1000")
self.ent_custom.returnPressed.connect(self._send_custom)
row.addWidget(BodyLabel("自定义:", card))
row.addWidget(self.ent_custom, 1)
row.addWidget(self._button("发送", self._send_custom))
body.addLayout(row)
layout.addWidget(card)
card, body = self._card("串口数据 · 收发日志(译码 + 原始 hex)",
trailing=self._button("清空", self._clear_log))
self.txt_log = QPlainTextEdit(self)
self.txt_log.setReadOnly(True)
self.txt_log.setFont(MONO)
self.txt_log.setLineWrapMode(QPlainTextEdit.NoWrap)
self.txt_log.setMinimumHeight(200)
# 裁剪走原生环形上限: 手工 cursor 裁剪在 PySide6 6.11 上抛 AttributeError
# (实例级枚举访问已移除), 曾致每 tick 异常中止 + 日志无限增长 + 显示滞后
self.txt_log.setMaximumBlockCount(500)
body.addWidget(self.txt_log)
self._log(f"帧格式: D5 01 LEN <命令> CRC / B5 TYPE LEN <payload> CRC; "
f"响应超时建议 {ff.REPLY_TIMEOUT * 1000:.0f}ms 重发")
layout.addWidget(card, 1) # 弹性填充: 右列底边与左列对齐
card, body = self._card("危险操作 · 会使设备复位")
row = QHBoxLayout()
self.btn_m888 = self._button("软件复位 M888", self._danger_m888)
self.btn_ota = self._button("进 OTA M9999", self._danger_ota)
setCustomStyleSheet(self.btn_m888, DANGER_QSS, DANGER_QSS)
setCustomStyleSheet(self.btn_ota, DANGER_QSS, DANGER_QSS)
row.addWidget(self.btn_m888)
row.addWidget(self.btn_ota)
row.addStretch(1)
body.addLayout(row)
body.addWidget(self._note(card, "复位后固件回到上电默认(加热 OFF / 目标 0.0℃); "
"2 秒后自动重查设备状态。"))
layout.addWidget(card)
@staticmethod
def _note(parent, text):
lbl = BodyLabel(text, parent)
lbl.setWordWrap(True)
return lbl
def _apply_settings(self):
if self.cbo_port is not None and config.SERIAL_CFG["port"]:
self._pick_port(config.SERIAL_CFG["port"])
self.ent_report.editingFinished.connect(self._save_report_pref)
widx = next((i for i, (_n, s) in enumerate(config.WINDOW_OPTIONS)
if s == config.WINDOW_SEC), 1)
self.cbo_window.setCurrentIndex(widx)
self.chart.set_window(config.WINDOW_SEC)
self.cbo_theme.setCurrentText(config.THEME)
# ================= 主题 =================
def _set_theme(self, value):
config.THEME = 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)
self.chart.set_colors(CHART_COLORS_DARK if is_dark else CHART_COLORS_LIGHT)
def _apply_palette(self, is_dark):
"""原生 Qt 控件与 Fluent 卡片表面色对齐(沿用 DLPilot 配色)"""
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};
}}
""")
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))
dwm.DwmSetWindowAttribute(hwnd, 35, ctypes.byref(colorref), ctypes.sizeof(colorref))
except Exception:
pass
# ================= 串口线程回调(后台线程, 只入队) =================
def _on_event(self, line):
self._rx_q.put(("event", line))
def _on_status(self, msg):
self._rx_q.put(("status", msg))
def _on_frame(self, frame, t_rx):
self._rx_q.put(("frame", frame, t_rx))
# ================= 主循环 tick =================
def _tick(self):
now = time.time()
try:
while True:
item = self._rx_q.get_nowait()
if item[0] == "status":
self._on_status_main(item[1])
elif item[0] == "event":
self._log(item[1])
else:
self._handle_frame(item[1], item[2])
except queue.Empty:
pass
connected = self.link.is_connected()
if connected != self._connected:
self._connected = connected
if connected and self._want:
self._connect_seq()
elif not connected:
self._pending.clear()
self._last_set = None
self._resume_report_ms = None
# 意图=连接但实际未建立(正在连接)或中途丢失(串口拔出/线程侧异常):
# 2s 宽限后判丢失, 按钮回"连接", 避免界面卡在"断开"与实际状态脱节
if self._want and not connected:
if self._want_since is None:
self._want_since = now
elif now - self._want_since > 2.0:
self._want = False
self._want_since = None
self.btn_conn.setText("连接")
self._pending.clear()
self._last_set = None
self._resume_report_ms = None
self.lbl_st.setText("连接未建立或已丢失, 请重试")
else:
self._want_since = None
# 应答超时: 按发送顺序逐个清理队首过期条目(监视台不自动重发,
# 命令全幂等, 交给用户重试; 设定命令的晚到 ok 走补偿, 见 _handle_frame)
while self._pending and now - self._pending[0]["t"] > PENDING_TIMEOUT:
entry = self._pending.popleft()
self._log(f'⏱ {entry["cmd"]} 无响应(>{PENDING_TIMEOUT:.1f}s)')
if entry["kind"] == "target":
self._resume_report() # M4 查询失败也要恢复上报
if entry.get("then"):
entry["then"]() # 链式流程(暂停上报→查目标)继续走
# 曲线时间轴推进 + 窗口统计(节流 0.5s)
if now - self._last_chart_repaint >= 0.5:
self._last_chart_repaint = now
self.chart.update()
self._update_stats()
def _on_status_main(self, msg):
self.lbl_st.setText(msg)
# 连接/断开/丢失全部入日志: 状态标签只留最后一条, 日志才可回溯排查
self._log(f"── {msg} ──")
# ================= 帧派发 =================
def _handle_frame(self, fr, t_rx):
if fr.type_code != 0xF0:
if self._pending: # 错误应答对应发送序队首
entry = self._pending.popleft()
self.lbl_st.setText(f'{entry["cmd"]} → {ff.describe(fr)}')
return
val, fault = ff.parse_temperature(fr.payload)
if fault: # NTC 开路/短路: 记故障, 不画点
if self._pending and self._pending[0]["kind"] == "temp":
self._pending.popleft()
self._record_fault(t_rx, fault)
return
if val is not None:
# 温度帧二义性(M1/M4/上报帧同为 ASCII 温度): 仅当队首在等目标温度
# 时拆出, 其余一律入实测曲线(M1 与上报读同一全局温度, 值恒相同)。
# M4 应答路由窗口 = 条目生命周期(适配器换向偶发延迟实测可 >0.5s);
# 上报开启时周期帧密集, 收紧到新鲜窗口, 视窗外温度帧按上报处理
head = self._pending[0] if self._pending else None
if head and head["kind"] == "target":
reporting = bool(self.report_state and self.report_state[0] == "ON")
window = RESPONSE_FRESH if reporting else PENDING_TIMEOUT
if t_rx - head["t"] <= window:
self._pending.popleft()
self._set_target(t_rx, val)
self._resume_report() # 暂停上报查目标的收尾
return
elif head and head["kind"] == "temp":
self._pending.popleft()
self._add_sample(t_rx, val)
return
# 非数值成功载荷: ok / ON / OFF / ON <ms> / v*
# 应答形态必须与队首期望匹配才消费, 不匹配记杂散(条目超时自清)
head = self._pending[0] if self._pending else None
pl = fr.payload.strip()
# 晚到 ok 补偿: 设定命令的 ok 延迟超过 1.2s 时已按超时清队(适配器换向
# 实测可 >4s), 迟到的 ok 在此按发送时记录的计划补偿应用, 避免状态
# chip 与设备脱节(实测: M2S100 ok 迟到, 上报 chip 停在 OFF 而设备在报)
if pl == "ok" and not (head and (head.get("apply") or head["kind"] == "ack")):
late = self._last_set
if late and t_rx - late["t"] <= 8.0:
self._last_set = None
self._log(f'⏱→ {late["cmd"]} ok 晚到, 补偿应用')
self._apply_ack({"cmd": late["cmd"], "kind": "ack",
"apply": late["apply"], "t": late["t"]}, t_rx)
elif head is not None:
self._log(f'杂散应答 ok(队首在等 {head["cmd"]})')
return
if head is None:
return
kind = head["kind"]
if pl == "ok" and (head.get("apply") or kind == "ack"):
self._pending.popleft()
self._apply_ack(head, t_rx)
if self._last_set and self._last_set.get("cmd") == head["cmd"]:
self._last_set = None
elif pl.startswith("v") and kind == "version" and len(pl) <= 16:
self._pending.popleft()
self.firmware = pl
self.lbl_ver.setText(f"固件: {pl}")
self.lbl_st.setText(f"固件版本 {pl}")
elif pl == "OFF" and kind == "report" or pl.startswith("ON ") and kind == "report":
st = ff.parse_report_state(pl)
if st[0]:
self._pending.popleft()
self._set_report_state(st[0], st[1])
elif pl in ("ON", "OFF") and kind == "heater":
self._pending.popleft()
self._set_heater(pl)
else:
self._log(f'杂散应答 {pl!r}(队首在等 {head["cmd"]})')
def _apply_ack(self, pending, t_rx):
"""M2S/M3S/M4S 设定命令的 ok 应答 -> 按 apply 计划更新本地状态"""
apply_plan = pending.get("apply")
if apply_plan:
what, value = apply_plan
if what == "target":
self._set_target(t_rx, float(value))
self.lbl_st.setText(f"目标温度已设定 {value} ℃")
elif what == "heater":
self._set_heater(value)
self.lbl_st.setText(f"加热已{'开启(PID 闭环)' if value == 'ON' else '关闭'}")
elif what == "report":
state, ms = value
self._set_report_state(state, ms)
self.lbl_st.setText("周期上报已开启" if state == "ON" else "周期上报已停止")
else:
self.lbl_st.setText("设备应答 ok")
then = pending.get("then")
if then:
then()
# ================= 数据通路 =================
def _add_sample(self, t, v):
self.sensor_state = "OK"
self._set_chip(self.chip_sensor, "NTC: 正常", CHIP_OK)
self.samples_t.append(t)
self.samples_v.append(v)
if len(self.samples_t) > config.MAX_SAMPLES:
excess = len(self.samples_t) - config.MAX_SAMPLES
del self.samples_t[:excess]
del self.samples_v[:excess]
self.lbl_temp.setText(f"{v:.1f} ℃")
self._csv_write(t, f"{v:.1f}", ok=True)
self.chart.update()
def _record_fault(self, t, code):
self.sensor_state = code
name = "开路" if code == ff.SENSOR_OPEN else "短路"
self._set_chip(self.chip_sensor, f"NTC: {name}({code})", CHIP_ERR)
self.samples_t.append(t)
self.samples_v.append(float("nan"))
self.faults.append((t, code))
if len(self.faults) > MAX_FAULTS: # 封顶: 抖动场景不无限增长(_draw_faults 按 paint 全量扫描)
del self.faults[:len(self.faults) - MAX_FAULTS]
self.lbl_temp.setText("—.— ℃")
self._csv_write(t, code, ok=False)
self.chart.update()
def _set_target(self, t, v):
self.target_hist.append((t, v))
if len(self.target_hist) > 5000:
del self.target_hist[:len(self.target_hist) - 5000]
self._set_chip(self.chip_target, f"目标: {v:g} ℃", CHIP_WARN)
self.chart.update()
def _set_heater(self, state):
self.heater_state = state
self._set_chip(self.chip_heater, f"加热: {state}",
CHIP_ERR if state == "ON" else CHIP_IDLE)
def _set_report_state(self, state, ms):
self.report_state = (state, ms)
text = f"上报: ON {ms}ms" if state == "ON" else "上报: OFF"
self._set_chip(self.chip_report, text, CHIP_OK if state == "ON" else CHIP_IDLE)
def _update_stats(self):
t0, t1 = self.chart._view()
vals = [v for t, v in zip(self.samples_t, self.samples_v)
if t0 <= t <= t1 and v == v]
if not vals:
self.lbl_stats.setText("窗口统计: —")
return
mean = sum(vals) / len(vals)
self.lbl_stats.setText(f"窗口统计: 最小 {min(vals):.1f} / 最大 {max(vals):.1f} / "
f"平均 {mean:.1f} ℃ · {len(vals)} 点")
# ================= 发送路径 =================
def _send_cmd(self, cmd, kind, apply_plan=None, then=None):
"""带应答跟踪发送: kind 决定应答派发, apply_plan 为 ok 后的本地状态计划,
then 为 ok 消费后的链式回调(暂停上报→查目标用)。
条目入 FIFO(半双工应答按发送序返回); 队列超长只留最新 8 条防失控"""
if not self.link.is_connected():
self.lbl_st.setText("未连接串口")
return False
# 幂等去重: 同一命令已有未决应答时忽略本次发送(连点"停止/开启"不再堆积,
# 真机实测连点 12 次 M2S0 挤爆 8 条 pending 队列; 协议全幂等, 同命令
# 一条在途足够, 超时自清后自然允许重发)
if any(e["cmd"] == cmd for e in self._pending):
self.lbl_st.setText(f"{cmd} 应答中, 已忽略重复发送")
return True
self._pending.append({"cmd": cmd, "kind": kind, "t": time.time(),
"apply": apply_plan, "then": then})
if apply_plan:
self._last_set = {"cmd": cmd, "t": time.time(), "apply": apply_plan}
while len(self._pending) > 8:
dropped = self._pending.popleft()
self._log(f'丢弃积压命令: {dropped["cmd"]}')
self.link.send(cmd)
return True
def _query(self, cmd, kind):
self._send_cmd(cmd, kind)
def _send_set(self, cmd, kind, value):
self._send_cmd(cmd, kind, apply_plan=(kind, value))
def _send_target(self):
try:
t = ff.validate_target_temp(self.ent_target.text())
except ValueError as e:
self.lbl_st.setText(f"目标温度非法: {e}")
return
self.ent_target.setText(str(t))
self.sld_target.setValue(t)
self._send_set(f"M4S{t}", "target", t)
def _heater_on(self):
if not self._confirm("开启加热",
"加热已实装: 开启后固件将按目标温度 PID 闭环真实加热模具,\n"
"表面可达 0~200℃(NTC 故障/过温时固件自动断电保护)。\n"
"确认开启?"):
return
self._send_set("M3S1", "heater", "ON")
def _report_on(self):
try:
ms = ff.validate_report_interval(self.ent_report.text())
except ValueError as e:
self.lbl_st.setText(f"上报间隔非法: {e}")
return
self.ent_report.setText(str(ms))
self._send_set(f"M2S{ms}", "report", ("ON", ms))
def _send_preset(self):
label = self.cbo_preset.currentText()
for name, cmd, _note in ff.PRESETS:
if not label.startswith(name + " "):
continue
if cmd in ff.RESET_COMMANDS:
self._danger_reset(cmd)
elif cmd == "M3S1":
self._heater_on()
elif cmd == "M2S500":
self._send_set(cmd, "report", ("ON", 500))
elif cmd == "M4S120":
self._send_set(cmd, "target", 120)
elif cmd == "M2S0":
self._send_set(cmd, "report", ("OFF", None))
elif cmd == "M3S0":
self._send_set(cmd, "heater", "OFF")
elif cmd == "M4":
self._query_target() # 上报开启时需暂停/恢复, 不能直发
else:
kind = {"M1": "temp", "M2": "report", "M3": "heater",
"M999": "version"}.get(cmd, "ack")
self._send_cmd(cmd, kind)
return
def _send_custom(self):
cmd = self.ent_custom.text().strip()
if not cmd:
return
if cmd in ff.RESET_COMMANDS:
self._danger_reset(cmd)
return
self._send_cmd(cmd, "ack")
self.ent_custom.clear()
def _danger_reset(self, cmd):
ota = cmd == "M9999"
if not self._confirm("软件复位" if not ota else "进入 OTA 模式",
"固件将复位" + ("进入 Bootloader(未部署前等同复位)。"
if ota else "。")
+ "复位后设备回到上电默认状态, 确定继续?"):
return
if self._send_cmd(cmd, "ack"):
self._log(f"⚠ {cmd} 已下发, 2 秒后自动重查设备状态")
QTimer.singleShot(2000, self._connect_seq_if_connected)
def _danger_m888(self):
self._danger_reset("M888")
def _danger_ota(self):
self._danger_reset("M9999")
def _confirm(self, title, text):
box = QMessageBox(self)
box.setWindowTitle(title)
box.setText(text)
box.setIcon(QMessageBox.Warning)
box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
box.setDefaultButton(QMessageBox.No)
return box.exec() == QMessageBox.Yes
# ================= 连接 =================
def _refresh_ports(self, _checked=False, silent=False):
self._ports = ff.FF485.list_ports_info()
self.cbo_port.clear()
self.cbo_port.addItems([label for _d, label in self._ports])
if self._ports:
self._pick_port(config.SERIAL_CFG["port"] or self._ports[0][0])
elif not silent:
self.lbl_st.setText("未发现串口设备")
def _pick_port(self, device):
for dev, label in self._ports:
if dev == device:
self.cbo_port.setCurrentText(label)
return
def _selected_port(self):
label = self.cbo_port.currentText()
for device, shown in self._ports:
if shown == label:
return device
return label.strip()
def _toggle_connect(self):
# 按钮按"意图"立即翻转, 实际开/关由串口线程异步完成并经状态日志反馈;
# 不再用 is_connected() 判断该走哪条分支(线程侧状态滞后会点不动)
if self._want:
self._want = False
self._want_since = None
self.btn_conn.setText("连接")
self.lbl_st.setText("正在断开…")
self._pending.clear()
self._last_set = None
self._resume_report_ms = None
self.link.close()
return
port = self._selected_port()
if not port:
self.lbl_st.setText("未选择串口, 请先选择或刷新")
return
try:
baud = int(self.cbo_baud.currentText())
except ValueError:
self.lbl_st.setText("波特率非法")
return
config.SERIAL_CFG.update(port=port, baud=baud)
config.save_settings()
self._want = True
self._want_since = time.time()
self.btn_conn.setText("断开")
self.lbl_st.setText("正在连接…")
self.link.open(port, baud)
def _connect_seq_if_connected(self):
if self.link.is_connected():
self._connect_seq()
def _connect_seq(self):
"""连接/复位后状态同步: 版本→上报→加热→(暂停上报→)目标。
每步发送前确认在线(线程异步打开期间 singleShot 早发会被"未连接串口"丢弃),
150ms 间隔规避半双工碰撞; 目标查询放最后并等 M2 应答消费完(见 _seq_target)"""
self._pending.clear()
self._last_set = None
self._seq = [("M999", "version"), ("M2", "report"), ("M3", "heater")]
QTimer.singleShot(0, self._seq_next)
def _seq_next(self):
if not self._seq:
QTimer.singleShot(0, self._seq_target)
return
if not self.link.is_connected():
return # 连接已断: 序列作废(重连会重跑)
cmd, kind = self._seq.pop(0)
self._query(cmd, kind)
QTimer.singleShot(150, self._seq_next)
def _seq_target(self):
if not self.link.is_connected():
return
if any(e["kind"] == "report" for e in self._pending):
QTimer.singleShot(100, self._seq_target) # 等 M2 应答落地再定分支
return
self._query_target()
def _query_target(self):
"""查询目标温度(M4)。上报开启时 M4 应答与周期温度帧同为 ASCII 浮点,
FIFO 窗口法在 100ms 上报下必现误占(实测目标 chip 显示成实测值)——
改为应答驱动的暂停/恢复: M2S0(ok)→M4→自动 M2S<ms> 恢复。"""
if not (self.report_state and self.report_state[0] == "ON"):
self._send_cmd("M4", "target")
return
self._resume_report_ms = self.report_state[1]
self.lbl_st.setText("查询目标: 先暂停上报…")
self._send_cmd("M2S0", "report", ("OFF", None), then=self._send_m4_after_pause)
def _send_m4_after_pause(self):
if self.link.is_connected():
QTimer.singleShot(120, lambda: self._send_cmd("M4", "target"))
def _resume_report(self):
ms, self._resume_report_ms = self._resume_report_ms, None
if ms and self.link.is_connected():
self._send_cmd(f"M2S{ms}", "report", ("ON", ms))
# ================= 曲线交互 =================
def _on_window(self, idx):
sec = config.WINDOW_OPTIONS[idx][1]
config.WINDOW_SEC = sec
config.save_settings()
self.chart.set_window(sec)
def _toggle_pause(self):
if self._frozen_at is None:
self._frozen_at = time.time()
self.btn_pause.setText("恢复")
else:
self._frozen_at = None
self.btn_pause.setText("暂停")
self.chart.set_frozen(self._frozen_at)
def _clear_plot(self):
del self.samples_t[:]
del self.samples_v[:]
del self.faults[:]
self.chart.update()
self._update_stats()
# ================= CSV 记录 =================
def _toggle_csv(self):
if self._csv is not None:
self._csv.close()
self._csv = None
self.btn_csv.setText("开始记录")
self.lbl_csv.setText(f"已停止, 共 {self._csv_count} 行 → "
f"{os.path.basename(self._csv_path)}")
return
os.makedirs(config.LOG_DIR, exist_ok=True)
path = os.path.join(config.LOG_DIR, time.strftime("temp_%Y%m%d_%H%M%S.csv"))
try:
f = open(path, "w", encoding="utf-8-sig", newline="")
except OSError as e:
self.lbl_csv.setText(f"创建 CSV 失败: {e}")
return
self._csv = f
self._csv_path = path
self._csv_t0 = time.time()
self._csv_count = 0
f.write("时间,相对秒,温度C,目标C,加热,传感器\n")
self.btn_csv.setText("停止记录")
self.lbl_csv.setText(f"记录中 → {path}")
def _csv_write(self, t, temp_text, ok):
if self._csv is None:
return
target = f"{self.target_hist[-1][1]:g}" if self.target_hist else ""
heater = self.heater_state or ""
sensor = self.sensor_state or ("OK" if ok else "")
try:
self._csv.write(f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(t))},"
f"{t - self._csv_t0:.3f},{temp_text},{target},{heater},{sensor}\n")
self._csv.flush()
except OSError as e:
# 写失败(磁盘满/记录盘被拔): 自动停记录。本函数在 _tick 帧处理路径上,
# 未捕获异常会中止整段消费(与 v0.2.8 日志裁剪同款失效管道, 勿回退)
self._csv.close()
self._csv = None
self.btn_csv.setText("开始记录")
self.lbl_csv.setText(f"记录失败已停止: {e}")
self._log(f"⚠ CSV 记录失败已停止: {e}")
return
self._csv_count += 1
self.lbl_csv.setText(f"记录中({self._csv_count} 行) → "
f"{os.path.basename(self._csv_path)}")
def _open_log_dir(self):
os.makedirs(config.LOG_DIR, exist_ok=True)
os.startfile(config.LOG_DIR)
# ================= 日志 =================
def _log(self, line):
sb = self.txt_log.verticalScrollBar()
at_bottom = sb.value() >= sb.maximum() - 4
self.txt_log.appendPlainText(time.strftime("%H:%M:%S ") + line)
if at_bottom: # 仅原本贴底时跟随, 不抢用户上翻看历史
sb.setValue(sb.maximum())
def _clear_log(self):
self.txt_log.clear()
def _about(self):
QMessageBox.about(self, f"关于 {APP_NAME}",
f"{APP_NAME} v{APP_VERSION}\n热压模具温控 RS485 上位机\n"
f"协议: protocols.md v1.0 (M1~M4 / M888 / M999 / M9999)\n\n{REPO_URL}")
# ================= 设置持久化小项 =================
def _save_report_pref(self):
try:
config.REPORT_MS = int(self.ent_report.text().strip())
except ValueError:
return
config.save_settings()
# ================= 生命周期 =================
def closeEvent(self, event):
config.THEME = self.cbo_theme.currentText()
config.save_settings()
if self._csv is not None:
self._csv.close()
self._csv = None
try:
self.link.close()
except Exception:
pass
super().closeEvent(event)
def _single_instance():
if os.name != "nt":
return True
try:
ctypes.windll.kernel32.CreateMutexW(None, False, "hulk.FilmForgeGUI.single")
return ctypes.windll.kernel32.GetLastError() != 183
except Exception:
return True
def _icon_path():
"""窗口图标路径: 打包版取 _MEIPASS(build_exe.py --add-data 内嵌), 源码版取 logos/。"""
bundled = os.path.join(getattr(sys, "_MEIPASS", ""), "filmforge.ico")
if os.path.exists(bundled):
return bundled
return os.path.join(os.path.dirname(os.path.abspath(__file__)),
"logos", "filmforge.ico")
def run_gui(auto_close=0):
if not _single_instance():
app = QApplication.instance() or QApplication(sys.argv)
QMessageBox.information(None, "FilmForge 已在运行", "检测到另一个 FilmForge 实例正在运行。")
return
app = QApplication.instance() or QApplication(sys.argv)
app.setApplicationName(APP_NAME)
if os.name == "nt":
try: # 任务栏独立标识: 缺省时任务栏可能沿用宿主默认图标并错误分组
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
"hulk.FilmForge.GUI")
except Exception:
pass
icon = _icon_path()
if os.path.exists(icon):
app.setWindowIcon(QIcon(icon)) # 主窗/弹窗/任务栏统一图标
setTheme(Theme.AUTO)
window = App(auto_close)
window.show()
app.exec()