generated from hulk/gd32f4xx_template_cmake_vscode
1238 lines
50 KiB
Python
1238 lines
50 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""FilmForge Fluent UI — 热压模具温控闭环监视台。
|
|
|
|
协议与串口模块(filmforge_rs485)保持不变, 本文件只做呈现层:
|
|
PySide6-Fluent-Widgets 卡片式双栏仪表盘 + 自绘滚动温度曲线(无第三方图表依赖)。
|
|
闭环监视 = 实测温度曲线 + 目标温度阶梯线同图 + 加热/上报/传感器状态联动;
|
|
设备未开启 M2 上报时可由上位机周期轮询 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, 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.1.0"
|
|
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 # 应答新鲜度: 该窗口内的温度帧优先按“查询应答”派发
|
|
|
|
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)
|
|
t_out, v_out = ts[lo:], vs[lo:]
|
|
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)...] 持有至下一变更时刻, 展开成阶梯点并延伸到右缘"""
|
|
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
|
|
b = min(tn, t1)
|
|
if not pts:
|
|
pts.append((max(ti, t0), vi))
|
|
pts.append((b, 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,
|
|
"暂无数据 — 连接设备后查询温度(M1)或开启上报(M2S<ms>)")
|
|
|
|
@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} — 热压模具温控监视台")
|
|
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'}]
|
|
self._last_poll = 0.0
|
|
self._frozen_at = None
|
|
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, root)
|
|
split.setChildrenCollapsible(False)
|
|
split.addWidget(self._scroll_column(self._build_left))
|
|
split.addWidget(self._scroll_column(self._build_right))
|
|
split.setStretchFactor(0, 3)
|
|
split.setStretchFactor(1, 2)
|
|
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)
|
|
return scroll
|
|
|
|
# ---------------- 左列: 监视 ----------------
|
|
def _build_left(self, 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):
|
|
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)
|
|
|
|
card, body = self._card("温度控制 · 目标 / 加热(固件预留)")
|
|
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, 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("查询", lambda: self._query("M4", "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, "当前固件加热/目标仅维护软件状态(ack+可回读), 硬件执行体"
|
|
"待接入; 接入后协议不变, 本页即可直接联调真实控温。"))
|
|
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)
|
|
row = QHBoxLayout()
|
|
row.addWidget(BodyLabel("自动轮询 M1", card))
|
|
self.cbo_poll = ComboBox(card)
|
|
for name, _ms in config.POLL_OPTIONS:
|
|
self.cbo_poll.addItem(name)
|
|
row.addWidget(self.cbo_poll)
|
|
row.addStretch(1)
|
|
body.addLayout(row)
|
|
body.addWidget(self._note(card, "上报开启时曲线走固件周期帧; 未开启时可由上位机按所选"
|
|
"周期自动查询 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)
|
|
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)
|
|
|
|
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"])
|
|
idx = next((i for i, (_n, ms) in enumerate(config.POLL_OPTIONS)
|
|
if ms == config.POLL_MS), 2)
|
|
self.cbo_poll.setCurrentIndex(idx) # 先设初值再连信号
|
|
self.cbo_poll.currentIndexChanged.connect(self._on_poll_changed)
|
|
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 self.btn_conn is not None:
|
|
self.btn_conn.setText("断开" if connected else "连接")
|
|
if connected:
|
|
self._connect_seq()
|
|
else:
|
|
self._pending.clear()
|
|
|
|
# 应答超时: 按发送顺序逐个清理队首过期条目(监视台不自动重发,
|
|
# 命令全幂等, 交给用户/自动轮询自然重试)
|
|
while self._pending and now - self._pending[0]["t"] > PENDING_TIMEOUT:
|
|
entry = self._pending.popleft()
|
|
self._log(f'⏱ {entry["cmd"]} 无响应(>{PENDING_TIMEOUT:.1f}s)')
|
|
|
|
# 自动轮询 M1: 仅在连接、未开启上报、无未决命令时补点
|
|
if (connected and config.POLL_MS > 0
|
|
and not (self.report_state and self.report_state[0] == "ON")
|
|
and not self._pending
|
|
and now - self._last_poll >= config.POLL_MS / 1000.0):
|
|
self._last_poll = now
|
|
self._send_cmd("M1", "temp")
|
|
|
|
# 曲线时间轴推进 + 窗口统计(节流 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)
|
|
if msg.startswith("已连接"):
|
|
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)
|
|
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
|
|
if head is None:
|
|
return
|
|
pl = fr.payload.strip()
|
|
kind = head["kind"]
|
|
if pl == "ok" and (head.get("apply") or kind == "ack"):
|
|
self._pending.popleft()
|
|
self._apply_ack(head, t_rx)
|
|
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"加热已{'开启' 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")
|
|
|
|
# ================= 数据通路 =================
|
|
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))
|
|
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):
|
|
"""带应答跟踪发送: kind 决定应答派发, apply_plan 为 ok 后的本地状态计划。
|
|
条目入 FIFO(半双工应答按发送序返回); 队列超长只留最新 8 条防失控"""
|
|
if not self.link.is_connected():
|
|
self.lbl_st.setText("未连接串口")
|
|
return False
|
|
self._pending.append({"cmd": cmd, "kind": kind, "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("开启加热",
|
|
"当前固件加热为预留功能(仅软件状态, 硬件执行体待接入)。\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")
|
|
else:
|
|
kind = {"M1": "temp", "M2": "report", "M3": "heater",
|
|
"M4": "target", "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):
|
|
if self.link.is_connected():
|
|
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.link.open(port, baud)
|
|
|
|
def _connect_seq_if_connected(self):
|
|
if self.link.is_connected():
|
|
self._connect_seq()
|
|
|
|
def _connect_seq(self):
|
|
"""连接/复位后状态同步: 版本→上报→加热→目标, 150ms 间隔规避半双工碰撞"""
|
|
self._pending.clear()
|
|
self._last_poll = time.time() # 序列期间抑制自动轮询, 防止抢占 M999 位
|
|
seq = (("M999", "version"), ("M2", "report"), ("M3", "heater"), ("M4", "target"))
|
|
for i, (cmd, kind) in enumerate(seq):
|
|
QTimer.singleShot(i * 150, lambda c=cmd, k=kind: self._query(c, k))
|
|
|
|
# ================= 曲线交互 =================
|
|
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 "")
|
|
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()
|
|
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):
|
|
self.txt_log.appendPlainText(time.strftime("%H:%M:%S ") + line)
|
|
if self.txt_log.blockCount() > 500:
|
|
cursor = self.txt_log.textCursor()
|
|
cursor.movePosition(cursor.Start)
|
|
cursor.select(cursor.BlockUnderCursor)
|
|
cursor.removeSelectedText()
|
|
cursor.deleteChar()
|
|
sb = self.txt_log.verticalScrollBar()
|
|
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 _on_poll_changed(self, idx):
|
|
config.POLL_MS = config.POLL_OPTIONS[idx][1]
|
|
config.save_settings()
|
|
|
|
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 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)
|
|
setTheme(Theme.AUTO)
|
|
window = App(auto_close)
|
|
window.show()
|
|
app.exec()
|