"""气体流量控制桌面程序。""" 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("", 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())