新增 Tkinter 单窗口操作界面和后台设备状态机,支持连接与断开 PLC、在线设置目标流量、实时显示传感器数据与下发总开度,并在监测及闭环阶段统一执行安全保护。 默认将 valve_model_004.json 内置到单文件 EXE,同时支持校验并选择外部阀模型;整理标定 JSON/PNG 到 valve_model 目录并同步配置路径。补充运行层离线测试、PyInstaller 构建配置、交付文件和完整的 README 使用及重新打包说明。
569 lines
21 KiB
Python
569 lines
21 KiB
Python
"""气体流量控制桌面程序。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import ctypes
|
||
from datetime import datetime
|
||
from queue import Empty
|
||
import sys
|
||
import tkinter as tk
|
||
from tkinter import filedialog, messagebox, ttk
|
||
|
||
import config
|
||
from control_runtime import (
|
||
BUILTIN_MODEL_NAME,
|
||
ControlWorker,
|
||
build_runtime_logger,
|
||
load_model,
|
||
validate_device_settings,
|
||
validate_target,
|
||
)
|
||
|
||
|
||
WINDOW_TITLE = "气体流量控制"
|
||
|
||
|
||
class FlowControlApp:
|
||
"""单窗口操作界面。"""
|
||
|
||
STATE_TEXT = {
|
||
"DISCONNECTED": "未连接",
|
||
"CONNECTING": "正在连接",
|
||
"MONITORING": "已连接 / 监测中",
|
||
"CONTROLLING": "闭环运行中",
|
||
"DISCONNECTING": "正在断开",
|
||
"STOPPING": "正在退出",
|
||
"FAULT": "故障 / 已断开",
|
||
}
|
||
|
||
STATE_COLORS = {
|
||
"DISCONNECTED": ("#5f6368", "#eef0f2"),
|
||
"CONNECTING": ("#875a00", "#fff3cd"),
|
||
"MONITORING": ("#0b6b3a", "#dff4e8"),
|
||
"CONTROLLING": ("#075cad", "#deefff"),
|
||
"DISCONNECTING": ("#875a00", "#fff3cd"),
|
||
"STOPPING": ("#875a00", "#fff3cd"),
|
||
"FAULT": ("#a61b1b", "#fde5e5"),
|
||
}
|
||
|
||
def __init__(self, root):
|
||
self.root = root
|
||
self.root.title(WINDOW_TITLE)
|
||
self.root.geometry("860x720")
|
||
self.root.minsize(780, 640)
|
||
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
|
||
|
||
self.logger = build_runtime_logger()
|
||
self.worker = ControlWorker(self.logger)
|
||
self.current_state = "DISCONNECTED"
|
||
self.closing = False
|
||
self.selected_model_path = None
|
||
self.model_info = load_model()
|
||
|
||
self.host_var = tk.StringVar(value=config.PLC_HOST)
|
||
self.port_var = tk.StringVar(value=str(config.PLC_PORT))
|
||
self.slave_var = tk.StringVar(value=str(config.PLC_SLAVE_ID))
|
||
self.model_var = tk.StringVar(value=self.model_info.display_name)
|
||
self.target_var = tk.StringVar(value="")
|
||
self.active_target_var = tk.StringVar(value="--")
|
||
self.status_var = tk.StringVar(value="未连接")
|
||
self.detail_var = tk.StringVar(value="请选择模型并连接设备")
|
||
|
||
self.value_vars = {
|
||
"flow": tk.StringVar(value="--"),
|
||
"pressure_before": tk.StringVar(value="--"),
|
||
"pressure_after": tk.StringVar(value="--"),
|
||
"opening": tk.StringVar(value="--"),
|
||
"motor": tk.StringVar(value="--"),
|
||
"feedforward": tk.StringVar(value="--"),
|
||
"correction": tk.StringVar(value="--"),
|
||
"mode": tk.StringVar(value="--"),
|
||
}
|
||
|
||
self._configure_style()
|
||
self._build_ui()
|
||
self._apply_state("DISCONNECTED", "未连接")
|
||
self._append_log(
|
||
f"已加载 {self.model_info.display_name};双击程序不会自动连接设备"
|
||
)
|
||
self.worker.start()
|
||
self.root.after(80, self._poll_worker_events)
|
||
|
||
def _configure_style(self):
|
||
self.root.option_add("*Font", ("Microsoft YaHei UI", 10))
|
||
style = ttk.Style(self.root)
|
||
try:
|
||
style.theme_use("vista")
|
||
except tk.TclError:
|
||
pass
|
||
style.configure("Title.TLabel", font=("Microsoft YaHei UI", 20, "bold"))
|
||
style.configure("Section.TLabelframe.Label", font=("Microsoft YaHei UI", 11, "bold"))
|
||
style.configure("Primary.TButton", font=("Microsoft YaHei UI", 10, "bold"))
|
||
style.configure("Danger.TButton", font=("Microsoft YaHei UI", 10, "bold"))
|
||
|
||
def _build_ui(self):
|
||
outer = ttk.Frame(self.root, padding=(18, 14, 18, 14))
|
||
outer.pack(fill="both", expand=True)
|
||
|
||
ttk.Label(outer, text=WINDOW_TITLE, style="Title.TLabel").pack(anchor="w")
|
||
ttk.Label(
|
||
outer,
|
||
text="Easy521 PLC · Modbus TCP · 10 Hz 闭环控制",
|
||
foreground="#5f6368",
|
||
).pack(anchor="w", pady=(0, 10))
|
||
|
||
warning = tk.Label(
|
||
outer,
|
||
text=(
|
||
"安全提示:连接成功后及断开设备前,程序会命令阀门至 100% 开度"
|
||
f"(行程 {config.MOTOR_OPEN_POSITION:g})。"
|
||
),
|
||
bg="#fff4e5",
|
||
fg="#8a4b00",
|
||
padx=10,
|
||
pady=7,
|
||
anchor="w",
|
||
)
|
||
warning.pack(fill="x", pady=(0, 10))
|
||
|
||
connection = ttk.LabelFrame(
|
||
outer,
|
||
text="设备与模型",
|
||
padding=10,
|
||
style="Section.TLabelframe",
|
||
)
|
||
connection.pack(fill="x")
|
||
connection.columnconfigure(1, weight=1)
|
||
connection.columnconfigure(3, weight=1)
|
||
|
||
ttk.Label(connection, text="PLC 地址").grid(row=0, column=0, sticky="w")
|
||
self.host_entry = ttk.Entry(connection, textvariable=self.host_var, width=20)
|
||
self.host_entry.grid(row=0, column=1, sticky="ew", padx=(7, 14))
|
||
ttk.Label(connection, text="端口").grid(row=0, column=2, sticky="w")
|
||
self.port_entry = ttk.Entry(connection, textvariable=self.port_var, width=9)
|
||
self.port_entry.grid(row=0, column=3, sticky="w", padx=(7, 14))
|
||
ttk.Label(connection, text="站号").grid(row=0, column=4, sticky="w")
|
||
self.slave_entry = ttk.Entry(connection, textvariable=self.slave_var, width=7)
|
||
self.slave_entry.grid(row=0, column=5, sticky="w", padx=(7, 0))
|
||
|
||
ttk.Label(connection, text="A_eff 模型").grid(
|
||
row=1, column=0, sticky="w", pady=(10, 0)
|
||
)
|
||
self.model_label = ttk.Label(
|
||
connection,
|
||
textvariable=self.model_var,
|
||
foreground="#174f85",
|
||
)
|
||
self.model_label.grid(
|
||
row=1, column=1, columnspan=3, sticky="w", padx=(7, 10), pady=(10, 0)
|
||
)
|
||
self.model_browse_button = ttk.Button(
|
||
connection, text="选择 JSON…", command=self._choose_model
|
||
)
|
||
self.model_browse_button.grid(row=1, column=4, pady=(10, 0), padx=(0, 7))
|
||
self.model_builtin_button = ttk.Button(
|
||
connection, text="使用内置", command=self._use_builtin_model
|
||
)
|
||
self.model_builtin_button.grid(row=1, column=5, pady=(10, 0))
|
||
|
||
button_row = ttk.Frame(connection)
|
||
button_row.grid(row=2, column=0, columnspan=6, sticky="ew", pady=(12, 0))
|
||
self.connect_button = ttk.Button(
|
||
button_row,
|
||
text="连接设备",
|
||
command=self._connect,
|
||
style="Primary.TButton",
|
||
width=16,
|
||
)
|
||
self.connect_button.pack(side="left")
|
||
self.disconnect_button = ttk.Button(
|
||
button_row,
|
||
text="断开设备",
|
||
command=self._disconnect,
|
||
style="Danger.TButton",
|
||
width=16,
|
||
)
|
||
self.disconnect_button.pack(side="left", padx=(10, 0))
|
||
|
||
target_frame = ttk.LabelFrame(
|
||
outer,
|
||
text="目标流量",
|
||
padding=10,
|
||
style="Section.TLabelframe",
|
||
)
|
||
target_frame.pack(fill="x", pady=(10, 0))
|
||
target_frame.columnconfigure(1, weight=1)
|
||
ttk.Label(target_frame, text="目标值").grid(row=0, column=0, sticky="w")
|
||
self.target_entry = ttk.Entry(
|
||
target_frame,
|
||
textvariable=self.target_var,
|
||
width=18,
|
||
font=("Microsoft YaHei UI", 12),
|
||
)
|
||
self.target_entry.grid(row=0, column=1, sticky="w", padx=(8, 5))
|
||
self.target_entry.bind("<Return>", lambda _event: self._set_target())
|
||
ttk.Label(target_frame, text="SLM").grid(row=0, column=2, sticky="w")
|
||
self.target_button = ttk.Button(
|
||
target_frame,
|
||
text="设置目标流量",
|
||
command=self._set_target,
|
||
style="Primary.TButton",
|
||
width=17,
|
||
)
|
||
self.target_button.grid(row=0, column=3, padx=(18, 0))
|
||
ttk.Label(target_frame, text="当前目标:").grid(
|
||
row=0, column=4, padx=(24, 0)
|
||
)
|
||
ttk.Label(
|
||
target_frame,
|
||
textvariable=self.active_target_var,
|
||
font=("Microsoft YaHei UI", 11, "bold"),
|
||
foreground="#075cad",
|
||
).grid(row=0, column=5, sticky="w")
|
||
ttk.Label(
|
||
target_frame,
|
||
text=(
|
||
f"允许范围 {config.TARGET_FLOW_MIN_SLM:g}~"
|
||
f"{config.TARGET_FLOW_MAX_SLM:g} SLM;首次设置即启动闭环"
|
||
),
|
||
foreground="#5f6368",
|
||
).grid(row=1, column=0, columnspan=6, sticky="w", pady=(7, 0))
|
||
|
||
telemetry = ttk.LabelFrame(
|
||
outer,
|
||
text="实时监视",
|
||
padding=10,
|
||
style="Section.TLabelframe",
|
||
)
|
||
telemetry.pack(fill="x", pady=(10, 0))
|
||
for column in range(4):
|
||
telemetry.columnconfigure(column, weight=1, uniform="card")
|
||
|
||
self._add_value_card(telemetry, 0, 0, "当前流量", "flow", "SLM")
|
||
self._add_value_card(telemetry, 0, 1, "阀前压力", "pressure_before", "kPa")
|
||
self._add_value_card(telemetry, 0, 2, "阀后压力", "pressure_after", "kPa")
|
||
self._add_value_card(
|
||
telemetry, 0, 3, "下发总开度", "opening", "%", prominent=True
|
||
)
|
||
self._add_value_card(telemetry, 1, 0, "电机行程", "motor", "")
|
||
self._add_value_card(telemetry, 1, 1, "前馈开度", "feedforward", "%")
|
||
self._add_value_card(telemetry, 1, 2, "PI 修正", "correction", "%")
|
||
self._add_value_card(telemetry, 1, 3, "PID 模式", "mode", "")
|
||
|
||
status_frame = ttk.LabelFrame(
|
||
outer,
|
||
text="运行状态",
|
||
padding=10,
|
||
style="Section.TLabelframe",
|
||
)
|
||
status_frame.pack(fill="both", expand=True, pady=(10, 0))
|
||
status_header = ttk.Frame(status_frame)
|
||
status_header.pack(fill="x")
|
||
self.status_badge = tk.Label(
|
||
status_header,
|
||
textvariable=self.status_var,
|
||
padx=10,
|
||
pady=4,
|
||
font=("Microsoft YaHei UI", 10, "bold"),
|
||
)
|
||
self.status_badge.pack(side="left")
|
||
ttk.Label(status_header, textvariable=self.detail_var).pack(
|
||
side="left", padx=(10, 0)
|
||
)
|
||
|
||
self.log_text = tk.Text(
|
||
status_frame,
|
||
height=3,
|
||
wrap="word",
|
||
state="disabled",
|
||
bg="#fafafa",
|
||
relief="solid",
|
||
borderwidth=1,
|
||
font=("Microsoft YaHei UI", 9),
|
||
)
|
||
self.log_text.pack(fill="both", expand=True, pady=(8, 0))
|
||
|
||
def _add_value_card(self, parent, row, column, title, key, unit, prominent=False):
|
||
frame = tk.Frame(
|
||
parent,
|
||
bg="#edf5ff" if prominent else "#f6f7f8",
|
||
highlightbackground="#b8c6d5" if prominent else "#d9dde1",
|
||
highlightthickness=1,
|
||
padx=10,
|
||
pady=8,
|
||
)
|
||
frame.grid(
|
||
row=row,
|
||
column=column,
|
||
sticky="nsew",
|
||
padx=(0 if column == 0 else 5, 0),
|
||
pady=(0 if row == 0 else 7, 0),
|
||
)
|
||
tk.Label(
|
||
frame,
|
||
text=title,
|
||
bg=frame["bg"],
|
||
fg="#4c545c",
|
||
font=("Microsoft YaHei UI", 9),
|
||
).pack(anchor="w")
|
||
value_row = tk.Frame(frame, bg=frame["bg"])
|
||
value_row.pack(anchor="w", pady=(3, 0))
|
||
tk.Label(
|
||
value_row,
|
||
textvariable=self.value_vars[key],
|
||
bg=frame["bg"],
|
||
fg="#075cad" if prominent else "#202124",
|
||
font=("Microsoft YaHei UI", 16 if prominent else 14, "bold"),
|
||
).pack(side="left")
|
||
if unit:
|
||
tk.Label(
|
||
value_row,
|
||
text=f" {unit}",
|
||
bg=frame["bg"],
|
||
fg="#5f6368",
|
||
font=("Microsoft YaHei UI", 9),
|
||
).pack(side="left", anchor="s", pady=(0, 2))
|
||
|
||
def _connect(self):
|
||
try:
|
||
settings = validate_device_settings(
|
||
self.host_var.get(), self.port_var.get(), self.slave_var.get()
|
||
)
|
||
# 在主线程先校验一次,避免无效模型进入连接流程。
|
||
self.model_info = load_model(self.selected_model_path)
|
||
except ValueError as exc:
|
||
messagebox.showerror("无法连接", str(exc), parent=self.root)
|
||
return
|
||
|
||
self._apply_state("CONNECTING", f"正在连接 {settings.host}:{settings.port}…")
|
||
self._append_log(f"请求连接 {settings.host}:{settings.port},站号 {settings.slave_id}")
|
||
self.worker.request_connect(settings, self.selected_model_path)
|
||
|
||
def _disconnect(self):
|
||
self._apply_state("DISCONNECTING", "正在安全断开设备…")
|
||
self._append_log("请求停止控制并安全断开设备")
|
||
self.worker.request_disconnect()
|
||
|
||
def _set_target(self):
|
||
try:
|
||
value = validate_target(self.target_var.get())
|
||
except ValueError as exc:
|
||
messagebox.showerror("目标流量无效", str(exc), parent=self.root)
|
||
return
|
||
self.detail_var.set(f"正在设置目标流量 {value:g} SLM…")
|
||
self.worker.request_target(value)
|
||
|
||
def _choose_model(self):
|
||
path = filedialog.askopenfilename(
|
||
parent=self.root,
|
||
title="选择 A_eff 阀模型",
|
||
filetypes=(("JSON 阀模型", "*.json"), ("所有文件", "*.*")),
|
||
)
|
||
if not path:
|
||
return
|
||
try:
|
||
info = load_model(path)
|
||
except ValueError as exc:
|
||
messagebox.showerror("模型无效", str(exc), parent=self.root)
|
||
return
|
||
self.selected_model_path = str(info.path)
|
||
self.model_info = info
|
||
self.model_var.set(info.display_name)
|
||
monotonic_text = "单调" if info.is_monotonic else "非单调(按关闭端优先反解)"
|
||
self._append_log(f"已选择 {info.display_name},{monotonic_text}")
|
||
|
||
def _use_builtin_model(self):
|
||
try:
|
||
info = load_model()
|
||
except ValueError as exc:
|
||
messagebox.showerror("内置模型不可用", str(exc), parent=self.root)
|
||
return
|
||
self.selected_model_path = None
|
||
self.model_info = info
|
||
self.model_var.set(info.display_name)
|
||
self._append_log(f"已切换为 {info.display_name}")
|
||
|
||
def _poll_worker_events(self):
|
||
try:
|
||
while True:
|
||
event = self.worker.events.get_nowait()
|
||
self._handle_worker_event(event)
|
||
except Empty:
|
||
pass
|
||
if not self.closing:
|
||
self.root.after(80, self._poll_worker_events)
|
||
elif self.worker.is_alive():
|
||
self.root.after(80, self._poll_worker_events)
|
||
else:
|
||
self.root.destroy()
|
||
|
||
def _handle_worker_event(self, event):
|
||
kind = event["kind"]
|
||
if kind == "state":
|
||
state = event["state"]
|
||
message = event.get("message", "")
|
||
self._apply_state(state, message)
|
||
if state != "DISCONNECTED" or message != "未连接":
|
||
self._append_log(message)
|
||
elif kind == "telemetry":
|
||
self._update_telemetry(event)
|
||
elif kind == "target_applied":
|
||
target = event["target"]
|
||
self.active_target_var.set(f"{target:g} SLM")
|
||
self._append_log(f"目标流量已生效:{target:g} SLM")
|
||
elif kind == "model_active":
|
||
self._append_log(f"当前控制模型:{event['display_name']}")
|
||
elif kind == "warning":
|
||
message = event.get("message", "未知警告")
|
||
self.detail_var.set(message)
|
||
self._append_log("警告:" + message)
|
||
elif kind == "fault":
|
||
message = event.get("message", "未知故障")
|
||
self._apply_state("FAULT", message)
|
||
self._append_log("故障:" + message)
|
||
if not self.closing:
|
||
messagebox.showerror("设备或控制故障", message, parent=self.root)
|
||
elif kind == "shutdown_complete" and self.closing:
|
||
errors = event.get("errors") or []
|
||
if errors:
|
||
self._append_log("退出警告:" + ";".join(errors))
|
||
|
||
def _update_telemetry(self, event):
|
||
self.value_vars["flow"].set(self._format_number(event.get("measured_flow_slm")))
|
||
self.value_vars["pressure_before"].set(
|
||
self._format_number(event.get("pressure_before_kpa"))
|
||
)
|
||
self.value_vars["pressure_after"].set(
|
||
self._format_number(event.get("pressure_kpa"))
|
||
)
|
||
self.value_vars["opening"].set(
|
||
self._format_number(event.get("opening_pct"), digits=2)
|
||
)
|
||
self.value_vars["motor"].set(
|
||
self._format_number(event.get("motor_position"), digits=1)
|
||
)
|
||
self.value_vars["feedforward"].set(
|
||
self._format_number(event.get("feedforward_pct"), digits=2)
|
||
)
|
||
correction = event.get("correction_pct")
|
||
self.value_vars["correction"].set(
|
||
"--" if correction is None else f"{float(correction):+.2f}"
|
||
)
|
||
self.value_vars["mode"].set(str(event.get("pid_mode") or "--"))
|
||
|
||
status = event.get("status", "")
|
||
if status and status not in {"OK", "MONITORING"}:
|
||
self.detail_var.set(f"采样状态:{status}")
|
||
|
||
@staticmethod
|
||
def _format_number(value, digits=2):
|
||
if value is None:
|
||
return "--"
|
||
return f"{float(value):.{digits}f}"
|
||
|
||
def _apply_state(self, state, detail):
|
||
self.current_state = state
|
||
self.status_var.set(self.STATE_TEXT.get(state, state))
|
||
self.detail_var.set(detail)
|
||
foreground, background = self.STATE_COLORS.get(
|
||
state, ("#202124", "#eef0f2")
|
||
)
|
||
if hasattr(self, "status_badge"):
|
||
self.status_badge.configure(fg=foreground, bg=background)
|
||
|
||
editable = state in {"DISCONNECTED", "FAULT"}
|
||
connected = state in {"MONITORING", "CONTROLLING"}
|
||
self._set_state(self.host_entry, "normal" if editable else "disabled")
|
||
self._set_state(self.port_entry, "normal" if editable else "disabled")
|
||
self._set_state(self.slave_entry, "normal" if editable else "disabled")
|
||
self._set_state(
|
||
self.model_browse_button, "normal" if editable else "disabled"
|
||
)
|
||
self._set_state(
|
||
self.model_builtin_button, "normal" if editable else "disabled"
|
||
)
|
||
self._set_state(self.connect_button, "normal" if editable else "disabled")
|
||
self._set_state(
|
||
self.disconnect_button, "normal" if connected else "disabled"
|
||
)
|
||
self._set_state(self.target_entry, "normal" if connected else "disabled")
|
||
self._set_state(self.target_button, "normal" if connected else "disabled")
|
||
if state in {"DISCONNECTED", "FAULT"}:
|
||
self.active_target_var.set("--")
|
||
|
||
@staticmethod
|
||
def _set_state(widget, state):
|
||
widget.configure(state=state)
|
||
|
||
def _append_log(self, message):
|
||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||
self.log_text.configure(state="normal")
|
||
self.log_text.insert("end", f"[{timestamp}] {message}\n")
|
||
self.log_text.see("end-1c")
|
||
self.log_text.configure(state="disabled")
|
||
|
||
def _on_close(self):
|
||
if self.closing:
|
||
return
|
||
self.closing = True
|
||
self._apply_state("STOPPING", "正在安全退出,请稍候…")
|
||
self._append_log("正在停止控制、恢复 100% 开度并关闭连接")
|
||
self.worker.request_shutdown()
|
||
|
||
|
||
def enable_dpi_awareness():
|
||
"""让 Windows 高 DPI 显示更清晰;失败时安全忽略。"""
|
||
if sys.platform != "win32":
|
||
return
|
||
try:
|
||
ctypes.windll.shcore.SetProcessDpiAwareness(1)
|
||
except (AttributeError, OSError):
|
||
try:
|
||
ctypes.windll.user32.SetProcessDPIAware()
|
||
except (AttributeError, OSError):
|
||
pass
|
||
|
||
|
||
def smoke_test():
|
||
"""供打包后自动检查资源和关键依赖,不连接设备。"""
|
||
config.validate_config()
|
||
info = load_model()
|
||
if info.point_count < 2 or info.path.name != BUILTIN_MODEL_NAME:
|
||
raise RuntimeError("内置阀模型校验失败")
|
||
from PcControl import Easy521ModbusClient # noqa: F401
|
||
|
||
return 0
|
||
|
||
|
||
def parse_args(argv=None):
|
||
parser = argparse.ArgumentParser(add_help=True)
|
||
parser.add_argument(
|
||
"--smoke-test",
|
||
action="store_true",
|
||
help="仅检查内置资源和依赖,不打开界面、不连接设备",
|
||
)
|
||
return parser.parse_args(argv)
|
||
|
||
|
||
def main(argv=None):
|
||
args = parse_args(argv)
|
||
if args.smoke_test:
|
||
return smoke_test()
|
||
|
||
enable_dpi_awareness()
|
||
root = tk.Tk()
|
||
try:
|
||
FlowControlApp(root)
|
||
except Exception as exc:
|
||
root.withdraw()
|
||
messagebox.showerror("程序启动失败", str(exc), parent=root)
|
||
root.destroy()
|
||
return 1
|
||
root.mainloop()
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|