#!/usr/bin/env python3 """Tkinter upper-computer test application for the PLSR Modbus firmware.""" from __future__ import annotations import queue import threading import time import tkinter as tk from pathlib import Path from tkinter import filedialog, messagebox, ttk from typing import Any, Optional from plsr_test import ( COMMAND_CLEAR_COUNT, COMMAND_IMMEDIATE_STOP, COMMAND_RESTART, COMMAND_RESET, COMMAND_STOP, CommunicationError, ModbusRtuClient, PlsrError, Snapshot, MAP_SPEC, MAP_LEGACY, REG_SEGMENT_BASE, REG_MONITOR_TOTAL_PULSES, REG_CONTROL, PlsrConfiguration, SegmentParameters, validate_frequency, list_ports, ) APP_TITLE = "PLSR 单轴脉冲测试台" POLL_INTERVAL_MS = 500 MAX_LOG_LINES = 800 STATE_TEXT = { 0: "空闲", 1: "准备", 2: "运行中", 3: "等待", 4: "减速中", 5: "完成", 6: "已停止", 7: "错误", } STOP_REASON_TEXT = { 0: "0 · 无", 1: "1 · 受控停止", 2: "2 · 立即停止", } ERROR_TEXT = { 0: "0 · 无错误", 1: "1 · 参数无效", 2: "2 · 设备忙", 3: "3 · 定时器资源错误", } REGISTER_NAMES = { 0: "运行状态", 1: "状态标志", 3: "当前频率 Hz", 5: "停止原因", 6: "最后错误", 10: "累计脉冲高字", 11: "累计脉冲低字", 100: "命令", 102: "目标脉冲高字", 103: "目标脉冲低字", 104: "设定频率 Hz", 105: "方向 0正/1反", 106: "脉冲输出 Q 点", 107: "方向输出 Q 点", } SPEC_REGISTER_NAMES = { 0x1000: "脉冲发送端子 Y0..Y3", 0x1001: "脉冲方向端子 Y12..Y15", 0x1002: "WAIT 信号 X4/X5", 0x1003: "EXT 信号 X4/X5", 0x1004: "发送模式", 0x1005: "方向延时 ms", 0x1006: "方向逻辑", 0x1007: "加减速模式", 0x1008: "运行模式", 0x1009: "总段数", 0x100A: "起始执行段", 0x100B: "默认速度高字", 0x100C: "默认速度低字", 0x100D: "起始速度高字", 0x100E: "起始速度低字", 0x1010: "终止速度高字", 0x1011: "终止速度低字", 0x1012: "加速时间 ms", 0x1013: "减速时间 ms", 0x2000: "累计脉冲高字", 0x2001: "累计脉冲低字", 0x2002: "频率高字", 0x2003: "频率低字", 0x2004: "运行状态", 0x2005: "当前执行段", 0x2006: "错误码", 0x2007: "停止原因", 0x3000: "控制 bit0启动/bit1停止/bit2清零/bit3重启/bit4复位/bit5立即停止", } class GuiModbusClient(ModbusRtuClient): """Send raw-frame messages to Tk through a thread-safe queue.""" def __init__(self, *args: Any, event_queue: queue.Queue, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self._event_queue = event_queue def _log_frame(self, prefix: str, frame: bytes) -> None: now = time.time() timestamp = time.strftime("%H:%M:%S", time.localtime(now)) + f".{int(now % 1 * 1000):03d}" self._event_queue.put( { "type": "log", "line": f"{timestamp} {prefix} {frame.hex(' ').upper()}", } ) class SerialWorker(threading.Thread): """Own the serial object so only one request can be active at a time.""" def __init__(self, jobs: queue.Queue, events: queue.Queue) -> None: super().__init__(name="plsr-serial-worker", daemon=True) self.jobs = jobs self.events = events self.client: Optional[GuiModbusClient] = None def _require_client(self) -> GuiModbusClient: if self.client is None: raise CommunicationError("设备尚未连接") return self.client def run(self) -> None: while True: job = self.jobs.get() name = job["name"] if name == "shutdown": if self.client is not None: self.client.close() return try: result: Any = None if name == "connect": if self.client is not None: self.client.close() self.client = GuiModbusClient( port=job["port"], baud_rate=job["baud"], slave=job["slave"], timeout=0.6, retries=2, verbose=False, event_queue=self.events, protocol=job.get("protocol", MAP_SPEC), ) try: self.client.open() result = self.client.read_snapshot() except Exception: self.client.close() self.client = None raise elif name == "disconnect": if self.client is not None: self.client.close() self.client = None elif name == "snapshot": result = self._require_client().read_snapshot() elif name == "read_config": result = self._require_client().read_configuration() elif name == "write_config": client = self._require_client() client.write_configuration(job["config"]) result = client.read_configuration() elif name == "write_config_command": client = self._require_client() client.write_configuration(job["config"]) client.command(job["command"]) result = client.read_snapshot() elif name == "raw": client = self._require_client() if client.protocol == MAP_SPEC: result = {"config": client.read_holding(0x1000, 0x14), "segments": [client.read_holding(REG_SEGMENT_BASE + i * 0x10, 8) for i in range(10)], "monitor": client.read_holding(REG_MONITOR_TOTAL_PULSES, 8), "control": client.read_holding(REG_CONTROL, 1)} else: result = {"status": client.read_holding(0, 32), "control": client.read_holding(100, 8)} elif name == "configure": result = self._require_client().configure_outputs( job["pulse_output"], job["direction_output"], save=True ) elif name == "start": client = self._require_client() client.start_motion(job["pulses"], job["frequency"], job["direction"], job.get("restart", False)) result = client.read_snapshot() elif name == "frequency": result = self._require_client().set_running_frequency( job["frequency"] ) elif name == "command": client = self._require_client() client.command(job["command"]) result = client.read_snapshot() else: raise ValueError(f"未知后台任务: {name}") self.events.put( { "type": "result", "name": name, "purpose": job.get("purpose", ""), "result": result, } ) except Exception as exc: self.events.put( { "type": "error", "name": name, "purpose": job.get("purpose", ""), "error": str(exc), } ) class PlsrGui(tk.Tk): def __init__(self) -> None: super().__init__() self.title(f"{APP_TITLE} - Python") self.geometry("1120x820") self.minsize(960, 680) self.configure(background="#f2f5f8") self.jobs: queue.Queue = queue.Queue() self.events: queue.Queue = queue.Queue() self.worker = SerialWorker(self.jobs, self.events) self.worker.start() self.connected = False self.job_active = False self.poll_active = False self.deferred_job: Optional[dict[str, Any]] = None self.last_snapshot: Optional[Snapshot] = None self.communication_errors = 0 self.closing = False self.log_line_count = 0 self.auto_phase: Optional[str] = None self.run_baseline = 0 self.run_target = 0 self.motion_plan: Optional[tuple[int, int, int]] = None self.lamp_test_baseline = 0 self._create_variables() self._configure_style() self._build_ui() self._refresh_ports() self._update_controls() self.protocol("WM_DELETE_WINDOW", self._on_close) self.after(50, self._drain_events) self.after(POLL_INTERVAL_MS, self._poll_tick) def _create_variables(self) -> None: self.port_var = tk.StringVar(value="COM9") self.baud_var = tk.StringVar(value="115200") self.slave_var = tk.StringVar(value="1") self.connection_var = tk.StringVar(value="未连接") self.operation_var = tk.StringVar(value="等待连接") self.pulse_output_var = tk.StringVar(value="Y0") self.direction_output_var = tk.StringVar(value="Y12") self.pulses_var = tk.StringVar(value="10") self.frequency_var = tk.StringVar(value="2") self.direction_var = tk.IntVar(value=0) self.capture_frames_var = tk.BooleanVar(value=True) self.protocol_var = tk.StringVar(value=MAP_SPEC) self.wait_input_var = tk.StringVar(value="X4") self.ext_input_var = tk.StringVar(value="X4") self.send_mode_var = tk.StringVar(value="完成模式") self.direction_delay_var = tk.StringVar(value="0") self.direction_logic_var = tk.StringVar(value="正逻辑") self.accel_mode_var = tk.StringVar(value="直线") self.run_mode_var = tk.StringVar(value="相对") self.segment_count_var = tk.StringVar(value="1") self.start_segment_var = tk.StringVar(value="1") self.default_speed_var = tk.StringVar(value="1000") self.start_speed_var = tk.StringVar(value="1") self.end_speed_var = tk.StringVar(value="1") self.accel_time_var = tk.StringVar(value="0") self.decel_time_var = tk.StringVar(value="0") self.segment_index_var = tk.StringVar(value="1") self.segment_frequency_var = tk.StringVar(value="1000") self.segment_pulses_var = tk.StringVar(value="10") self.segment_wait_var = tk.StringVar(value="WAIT时间") self.segment_wait_time_var = tk.StringVar(value="0") self.segment_act_time_var = tk.StringVar(value="0") self.segment_jump_var = tk.StringVar(value="0") self.segment_models = [SegmentParameters() for _ in range(10)] self.state_var = tk.StringVar(value="--") self.flags_var = tk.StringVar(value="--") self.current_frequency_var = tk.StringVar(value="--") self.total_pulses_var = tk.StringVar(value="--") self.stop_reason_var = tk.StringVar(value="--") self.error_var = tk.StringVar(value="--") self.saved_outputs_var = tk.StringVar(value="--") self.progress_text_var = tk.StringVar(value="0 / 0") def _configure_style(self) -> None: style = ttk.Style(self) if "clam" in style.theme_names(): style.theme_use("clam") default_font = ("Microsoft YaHei UI", 9) self.option_add("*Font", default_font) style.configure("TFrame", background="#f2f5f8") style.configure("Surface.TFrame", background="#ffffff") style.configure("TLabel", background="#ffffff", foreground="#20364d") style.configure("TCheckbutton", background="#ffffff", foreground="#20364d") style.configure("TRadiobutton", background="#ffffff", foreground="#20364d") style.configure("Header.TLabel", background="#f2f5f8", foreground="#123354", font=("Microsoft YaHei UI", 20, "bold")) style.configure("Subtitle.TLabel", background="#f2f5f8", foreground="#61758a") style.configure("Section.TLabel", background="#ffffff", foreground="#173b61", font=("Microsoft YaHei UI", 10, "bold")) style.configure("Value.TLabel", background="#ffffff", foreground="#102f4f", font=("Microsoft YaHei UI", 10, "bold")) style.configure("StatusOff.TLabel", background="#fdebea", foreground="#b42318", padding=(12, 7), font=("Microsoft YaHei UI", 9, "bold")) style.configure("StatusOn.TLabel", background="#e7f6ec", foreground="#167044", padding=(12, 7), font=("Microsoft YaHei UI", 9, "bold")) style.configure("Primary.TButton", foreground="#ffffff", background="#1f6fa8", padding=(10, 6), font=("Microsoft YaHei UI", 9, "bold")) style.map("Primary.TButton", background=[("active", "#185d8e"), ("disabled", "#aab7c2")]) style.configure("Danger.TButton", foreground="#ffffff", background="#c93b3b", padding=(10, 6), font=("Microsoft YaHei UI", 9, "bold")) style.map("Danger.TButton", background=[("active", "#a92f2f"), ("disabled", "#c9b2b2")]) style.configure("TLabelframe", background="#ffffff", padding=10) style.configure("TLabelframe.Label", background="#ffffff", foreground="#173b61", font=("Microsoft YaHei UI", 10, "bold")) style.configure("TNotebook", background="#f2f5f8", borderwidth=0) style.configure("TNotebook.Tab", padding=(16, 7)) def _build_ui(self) -> None: outer = ttk.Frame(self, padding=(22, 14, 22, 12)) outer.grid(row=0, column=0, sticky="nsew") self.rowconfigure(0, weight=1) self.columnconfigure(0, weight=1) outer.columnconfigure(0, weight=1) outer.rowconfigure(3, weight=1) header = ttk.Frame(outer) header.grid(row=0, column=0, sticky="ew", pady=(0, 10)) header.columnconfigure(0, weight=1) ttk.Label(header, text=APP_TITLE, style="Header.TLabel").grid(row=0, column=0, sticky="w") ttk.Label( header, text="XDM-60T4-E / STM32 · Modbus RTU · 可配置并掉电保存 Q 点", style="Subtitle.TLabel", ).grid(row=1, column=0, sticky="w", pady=(2, 0)) self.connection_label = ttk.Label(header, textvariable=self.connection_var, style="StatusOff.TLabel") self.connection_label.grid(row=0, column=1, rowspan=2, sticky="e") connection = ttk.LabelFrame(outer, text="串口连接") connection.grid(row=1, column=0, sticky="ew", pady=(0, 10)) connection.columnconfigure(8, weight=1) ttk.Label(connection, text="端口").grid(row=0, column=0, padx=(0, 6)) self.port_combo = ttk.Combobox(connection, textvariable=self.port_var, width=12, state="readonly") self.port_combo.grid(row=0, column=1, padx=(0, 8)) self.refresh_button = ttk.Button(connection, text="刷新", command=self._refresh_ports, width=9) self.refresh_button.grid(row=0, column=2, padx=(0, 20)) ttk.Label(connection, text="波特率").grid(row=0, column=3, padx=(0, 6)) self.baud_combo = ttk.Combobox(connection, textvariable=self.baud_var, values=("9600", "19200", "38400", "57600", "115200"), width=10, state="readonly") self.baud_combo.grid(row=0, column=4, padx=(0, 18)) ttk.Label(connection, text="格式 8-E-1").grid(row=0, column=5, padx=(0, 18)) ttk.Label(connection, text="站号").grid(row=0, column=6, padx=(0, 6)) self.slave_spin = ttk.Spinbox(connection, textvariable=self.slave_var, from_=1, to=247, width=7) self.slave_spin.grid(row=0, column=7) ttk.Label(connection, text="映射").grid(row=0, column=8, padx=(16, 6)) self.protocol_combo = ttk.Combobox(connection, textvariable=self.protocol_var, values=(MAP_SPEC, MAP_LEGACY), width=9, state="readonly") self.protocol_combo.grid(row=0, column=9, padx=(0, 8)) self.connect_button = ttk.Button(connection, text="连接设备", command=self._toggle_connection, style="Primary.TButton", width=14) self.connect_button.grid(row=0, column=10, sticky="e", padx=(8, 0)) self.operation_label = ttk.Label(outer, textvariable=self.operation_var, style="Subtitle.TLabel") self.operation_label.grid(row=2, column=0, sticky="w", pady=(0, 6)) notebook = ttk.Notebook(outer) notebook.grid(row=3, column=0, sticky="nsew") control_tab = ttk.Frame(notebook, style="Surface.TFrame", padding=10) config_tab = ttk.Frame(notebook, style="Surface.TFrame", padding=10) register_tab = ttk.Frame(notebook, style="Surface.TFrame", padding=10) log_tab = ttk.Frame(notebook, style="Surface.TFrame", padding=10) notebook.add(control_tab, text="控制与状态") notebook.add(config_tab, text="完整参数与多段") notebook.add(register_tab, text="寄存器诊断") notebook.add(log_tab, text="通信日志") self._build_control_tab(control_tab) self._build_config_tab(config_tab) self._build_register_tab(register_tab) self._build_log_tab(log_tab) notebook.select(control_tab) ttk.Label( outer, text="安全提示:首次发脉冲前请断开机械负载,仅连接测试灯或示波器;先低频验证 Q 点和方向电平。", foreground="#a13b22", background="#f2f5f8", ).grid(row=4, column=0, sticky="w", pady=(9, 0)) def _build_control_tab(self, parent: ttk.Frame) -> None: parent.columnconfigure(0, weight=1, uniform="panel") parent.columnconfigure(1, weight=1, uniform="panel") parent.rowconfigure(0, weight=1) left = ttk.Frame(parent, style="Surface.TFrame") right = ttk.Frame(parent, style="Surface.TFrame") left.grid(row=0, column=0, sticky="nsew", padx=(0, 7)) right.grid(row=0, column=1, sticky="nsew", padx=(7, 0)) left.columnconfigure(0, weight=1) right.columnconfigure(0, weight=1) output_box = ttk.LabelFrame(left, text="模块输出映射(保存到设备 Flash)") output_box.grid(row=0, column=0, sticky="ew", pady=(0, 10)) output_box.columnconfigure(1, weight=1) output_box.columnconfigure(3, weight=1) ttk.Label(output_box, text="脉冲点").grid(row=0, column=0, sticky="w", padx=(0, 6)) self.pulse_output_combo = ttk.Combobox(output_box, textvariable=self.pulse_output_var, values=tuple(f"Y{i}" for i in range(4)), state="readonly", width=5) self.pulse_output_combo.grid(row=0, column=1, sticky="ew", padx=(0, 16)) ttk.Label(output_box, text="方向点").grid(row=0, column=2, sticky="w", padx=(0, 6)) self.direction_output_combo = ttk.Combobox(output_box, textvariable=self.direction_output_var, values=tuple(f"Y{12 + i}" for i in range(4)), state="readonly", width=5) self.direction_output_combo.grid(row=0, column=3, sticky="ew", padx=(0, 12)) self.save_outputs_button = ttk.Button(output_box, text="写入并保存", command=self._save_outputs, style="Primary.TButton") self.save_outputs_button.grid(row=0, column=4) ttk.Label(output_box, text="保存成功后断电不会丢失,下次上电自动恢复。", foreground="#61758a", background="#ffffff").grid(row=1, column=0, columnspan=5, sticky="w", pady=(9, 0)) motion_box = ttk.LabelFrame(left, text="运动参数") motion_box.grid(row=1, column=0, sticky="ew", pady=(0, 10)) motion_box.columnconfigure(1, weight=1) ttk.Label(motion_box, text="目标脉冲数").grid(row=0, column=0, sticky="w", padx=(0, 12), pady=5) self.pulses_spin = ttk.Spinbox(motion_box, textvariable=self.pulses_var, from_=-2147483648, to=2147483647) self.pulses_spin.grid(row=0, column=1, sticky="ew", pady=5) ttk.Label(motion_box, text="频率").grid(row=1, column=0, sticky="w", padx=(0, 12), pady=5) freq_line = ttk.Frame(motion_box, style="Surface.TFrame") freq_line.grid(row=1, column=1, sticky="ew", pady=5) freq_line.columnconfigure(0, weight=1) self.frequency_spin = ttk.Spinbox(freq_line, textvariable=self.frequency_var, from_=1, to=100000) self.frequency_spin.grid(row=0, column=0, sticky="ew") ttk.Label(freq_line, text="Hz(1–100000)", background="#ffffff").grid(row=0, column=1, padx=(8, 0)) self.apply_frequency_button = ttk.Button( freq_line, text="应用频率", command=self._apply_running_frequency ) self.apply_frequency_button.grid(row=0, column=2, padx=(10, 0)) ttk.Label(motion_box, text="运行方向").grid(row=2, column=0, sticky="w", padx=(0, 12), pady=5) direction_line = ttk.Frame(motion_box, style="Surface.TFrame") direction_line.grid(row=2, column=1, sticky="w", pady=5) self.forward_radio = ttk.Radiobutton(direction_line, text="正向(0)", variable=self.direction_var, value=0) self.reverse_radio = ttk.Radiobutton(direction_line, text="反向(1)", variable=self.direction_var, value=1) self.forward_radio.grid(row=0, column=0, padx=(0, 18)) self.reverse_radio.grid(row=0, column=1) actions = ttk.LabelFrame(left, text="运行控制") actions.grid(row=2, column=0, sticky="ew") for column in range(3): actions.columnconfigure(column, weight=1) self.start_button = ttk.Button(actions, text="启动/继续脉冲", command=self._start_motion, style="Primary.TButton") self.stop_button = ttk.Button(actions, text="受控停止", command=lambda: self._send_command(COMMAND_STOP, "stop")) self.emergency_button = ttk.Button(actions, text="重新运行脉冲", command=self._restart_motion, style="Danger.TButton") self.start_button.grid(row=0, column=0, sticky="ew", padx=(0, 6), pady=(0, 7)) self.stop_button.grid(row=0, column=1, sticky="ew", padx=6, pady=(0, 7)) self.emergency_button.grid(row=0, column=2, sticky="ew", padx=(6, 0), pady=(0, 7)) self.lamp_test_button = ttk.Button(actions, text="低速正反转灯测", command=self._start_lamp_test) self.reset_button = ttk.Button(actions, text="复位状态", command=lambda: self._send_command(COMMAND_RESET, "reset")) self.clear_button = ttk.Button(actions, text="清零累计脉冲", command=lambda: self._send_command(COMMAND_CLEAR_COUNT, "clear")) self.lamp_test_button.grid(row=1, column=0, sticky="ew", padx=(0, 6)) self.reset_button.grid(row=1, column=1, sticky="ew", padx=6) self.clear_button.grid(row=1, column=2, sticky="ew", padx=(6, 0)) status_box = ttk.LabelFrame(right, text="实时状态") status_box.grid(row=0, column=0, sticky="nsew") status_box.columnconfigure(1, weight=1) rows = ( ("运行状态", self.state_var), ("状态标志", self.flags_var), ("当前频率", self.current_frequency_var), ("累计脉冲", self.total_pulses_var), ("停止原因", self.stop_reason_var), ("最后错误", self.error_var), ("设备当前 Q 点", self.saved_outputs_var), ) for row, (label, variable) in enumerate(rows): ttk.Label(status_box, text=label).grid(row=row, column=0, sticky="w", padx=(0, 24), pady=5) ttk.Label(status_box, textvariable=variable, style="Value.TLabel").grid(row=row, column=1, sticky="e", pady=5) ttk.Separator(status_box).grid(row=len(rows), column=0, columnspan=2, sticky="ew", pady=(5, 8)) ttk.Label(status_box, text="本次运动进度").grid(row=len(rows) + 1, column=0, columnspan=2, sticky="w") self.progress = ttk.Progressbar(status_box, maximum=100, value=0) self.progress.grid(row=len(rows) + 2, column=0, columnspan=2, sticky="ew", pady=(8, 4)) ttk.Label(status_box, textvariable=self.progress_text_var, background="#ffffff", foreground="#61758a").grid(row=len(rows) + 3, column=0, columnspan=2, sticky="e") self.read_now_button = ttk.Button(status_box, text="立即读取状态", command=lambda: self._submit("snapshot", purpose="manual")) self.read_now_button.grid(row=len(rows) + 4, column=0, columnspan=2, sticky="ew", pady=(10, 0)) def _build_register_tab(self, parent: ttk.Frame) -> None: parent.rowconfigure(1, weight=1) parent.columnconfigure(0, weight=1) toolbar = ttk.Frame(parent, style="Surface.TFrame") toolbar.grid(row=0, column=0, sticky="ew", pady=(0, 8)) toolbar.columnconfigure(0, weight=1) ttk.Label(toolbar, text="需求版读取 1000/1100/2000/3000;legacy 读取 0/100。", background="#ffffff", foreground="#61758a").grid(row=0, column=0, sticky="w") self.raw_button = ttk.Button(toolbar, text="刷新寄存器", command=lambda: self._submit("raw", purpose="raw"), style="Primary.TButton") self.raw_button.grid(row=0, column=1, sticky="e") tree_frame = ttk.Frame(parent, style="Surface.TFrame") tree_frame.grid(row=1, column=0, sticky="nsew") tree_frame.rowconfigure(0, weight=1) tree_frame.columnconfigure(0, weight=1) self.register_tree = ttk.Treeview(tree_frame, columns=("address", "hex", "value", "meaning"), show="headings", selectmode="browse") self.register_tree.heading("address", text="地址") self.register_tree.heading("hex", text="十六进制") self.register_tree.heading("value", text="无符号值") self.register_tree.heading("meaning", text="项目定义") self.register_tree.column("address", width=90, anchor="center", stretch=False) self.register_tree.column("hex", width=130, anchor="center", stretch=False) self.register_tree.column("value", width=140, anchor="e", stretch=False) self.register_tree.column("meaning", width=340, anchor="w") scroll = ttk.Scrollbar(tree_frame, orient="vertical", command=self.register_tree.yview) self.register_tree.configure(yscrollcommand=scroll.set) self.register_tree.grid(row=0, column=0, sticky="nsew") scroll.grid(row=0, column=1, sticky="ns") def _build_config_tab(self, parent: ttk.Frame) -> None: parent.columnconfigure(0, weight=1) parent.rowconfigure(1, weight=1) global_box = ttk.LabelFrame(parent, text="系统与运动参数(0x1000..0x1013)") global_box.grid(row=0, column=0, sticky="ew", pady=(0, 10)) for column in (1, 3, 5, 7): global_box.columnconfigure(column, weight=1) fields = ( ("WAIT信号", self.wait_input_var, ("X4", "X5")), ("EXT信号", self.ext_input_var, ("X4", "X5")), ("发送模式", self.send_mode_var, ("完成模式", "后续模式")), ("方向逻辑", self.direction_logic_var, ("正逻辑", "负逻辑")), ("加减速模式", self.accel_mode_var, ("直线", "S曲线", "正弦曲线")), ("运行模式", self.run_mode_var, ("相对", "绝对")), ) for index, (label, variable, values) in enumerate(fields): row, pair = divmod(index, 3) column = pair * 2 ttk.Label(global_box, text=label).grid(row=row, column=column, sticky="w", padx=(0, 6), pady=4) ttk.Combobox(global_box, textvariable=variable, values=values, state="readonly", width=12).grid(row=row, column=column + 1, sticky="ew", padx=(0, 14), pady=4) numeric_fields = ( ("方向延时 ms", self.direction_delay_var, 0, 65535), ("总段数", self.segment_count_var, 1, 10), ("起始段", self.start_segment_var, 1, 10), ("默认速度 Hz", self.default_speed_var, 1, 100000), ("起始速度 Hz", self.start_speed_var, 0, 100000), ("终止速度 Hz", self.end_speed_var, 0, 100000), ("加速时间 ms", self.accel_time_var, 0, 65535), ("减速时间 ms", self.decel_time_var, 0, 65535), ) for index, (label, variable, minimum, maximum) in enumerate(numeric_fields): row, pair = divmod(index, 4) row += 2 column = pair * 2 ttk.Label(global_box, text=label).grid(row=row, column=column, sticky="w", padx=(0, 6), pady=4) ttk.Spinbox(global_box, textvariable=variable, from_=minimum, to=maximum, width=12).grid(row=row, column=column + 1, sticky="ew", padx=(0, 14), pady=4) body = ttk.Frame(parent, style="Surface.TFrame") body.grid(row=1, column=0, sticky="nsew") body.columnconfigure(0, weight=3) body.columnconfigure(1, weight=2) body.rowconfigure(0, weight=1) table_box = ttk.LabelFrame(body, text="1~10 段参数(0x1100..0x1197)") table_box.grid(row=0, column=0, sticky="nsew", padx=(0, 7)) table_box.rowconfigure(0, weight=1) table_box.columnconfigure(0, weight=1) self.segment_tree = ttk.Treeview(table_box, columns=("segment", "frequency", "pulses", "wait", "wait_ms", "act_ms", "jump"), show="headings", height=10) for key, title, width in (("segment", "段", 45), ("frequency", "频率 Hz", 85), ("pulses", "脉冲数", 100), ("wait", "等待条件", 130), ("wait_ms", "WAIT ms", 75), ("act_ms", "ACT ms", 75), ("jump", "跳转", 55)): self.segment_tree.heading(key, text=title) self.segment_tree.column(key, width=width, anchor="center", stretch=key == "wait") self.segment_tree.grid(row=0, column=0, sticky="nsew") self.segment_tree.bind("<>", self._select_segment) editor = ttk.LabelFrame(body, text="当前段编辑") editor.grid(row=0, column=1, sticky="nsew", padx=(7, 0)) editor.columnconfigure(1, weight=1) segment_fields = ( ("段号", self.segment_index_var, 1, 10), ("频率 Hz", self.segment_frequency_var, 0, 100000), ("脉冲数", self.segment_pulses_var, -2147483648, 2147483647), ("WAIT时间 ms", self.segment_wait_time_var, 0, 65535), ("ACT时间 ms", self.segment_act_time_var, 0, 65535), ("跳转段(0默认)", self.segment_jump_var, 0, 10), ) for row, (label, variable, minimum, maximum) in enumerate(segment_fields): ttk.Label(editor, text=label).grid(row=row, column=0, sticky="w", padx=(0, 8), pady=5) widget = ttk.Spinbox(editor, textvariable=variable, from_=minimum, to=maximum) if row == 0: widget.configure(state="readonly") widget.grid(row=row, column=1, sticky="ew", pady=5) ttk.Label(editor, text="等待条件").grid(row=6, column=0, sticky="w", padx=(0, 8), pady=5) ttk.Combobox(editor, textvariable=self.segment_wait_var, values=("WAIT时间", "WAIT信号", "ACT时间", "EXT信号", "EXT或完成"), state="readonly").grid(row=6, column=1, sticky="ew", pady=5) self.apply_segment_button = ttk.Button(editor, text="应用当前段", command=self._apply_segment_editor) self.apply_segment_button.grid(row=7, column=0, columnspan=2, sticky="ew", pady=(10, 5)) button_line = ttk.Frame(editor, style="Surface.TFrame") button_line.grid(row=8, column=0, columnspan=2, sticky="ew") button_line.columnconfigure((0, 1), weight=1) self.read_config_button = ttk.Button(button_line, text="从设备读取全部", command=self._read_full_config) self.write_config_button = ttk.Button(button_line, text="校验并写入全部", command=self._write_full_config, style="Primary.TButton") self.read_config_button.grid(row=0, column=0, sticky="ew", padx=(0, 4)) self.write_config_button.grid(row=0, column=1, sticky="ew", padx=(4, 0)) config_actions = ttk.Frame(editor, style="Surface.TFrame") config_actions.grid(row=9, column=0, columnspan=2, sticky="ew", pady=(8, 0)) config_actions.columnconfigure((0, 1, 2), weight=1) self.config_start_button = ttk.Button( config_actions, text="写入并启动/继续", command=lambda: self._start_config_sequence(False), style="Primary.TButton") self.config_restart_button = ttk.Button( config_actions, text="写入并重新启动", command=lambda: self._start_config_sequence(True), style="Danger.TButton") self.config_stop_button = ttk.Button( config_actions, text="停止", command=lambda: self._send_command(COMMAND_STOP, "stop")) self.config_start_button.grid(row=0, column=0, sticky="ew", padx=(0, 4)) self.config_restart_button.grid(row=0, column=1, sticky="ew", padx=4) self.config_stop_button.grid(row=0, column=2, sticky="ew", padx=(4, 0)) config_actions2 = ttk.Frame(editor, style="Surface.TFrame") config_actions2.grid(row=10, column=0, columnspan=2, sticky="ew", pady=(5, 0)) config_actions2.columnconfigure((0, 1, 2), weight=1) self.config_emergency_button = ttk.Button( config_actions2, text="立即停止", command=lambda: self._send_command(COMMAND_IMMEDIATE_STOP, "immediate_stop")) self.config_reset_button = ttk.Button( config_actions2, text="复位", command=lambda: self._send_command(COMMAND_RESET, "reset")) self.config_clear_button = ttk.Button( config_actions2, text="清零累计脉冲", command=lambda: self._send_command(COMMAND_CLEAR_COUNT, "clear")) self.config_emergency_button.grid(row=0, column=0, sticky="ew", padx=(0, 4)) self.config_reset_button.grid(row=0, column=1, sticky="ew", padx=4) self.config_clear_button.grid(row=0, column=2, sticky="ew", padx=(4, 0)) ttk.Label(editor, text="X4/X5、实际频率和曲线需配合示波器/人工触发验收。", wraplength=300, foreground="#61758a", background="#ffffff").grid(row=11, column=0, columnspan=2, sticky="w", pady=(10, 0)) self._refresh_segment_tree() def _build_log_tab(self, parent: ttk.Frame) -> None: parent.rowconfigure(1, weight=1) parent.columnconfigure(0, weight=1) toolbar = ttk.Frame(parent, style="Surface.TFrame") toolbar.grid(row=0, column=0, sticky="ew", pady=(0, 8)) toolbar.columnconfigure(0, weight=1) ttk.Checkbutton(toolbar, text="记录原始 TX / RX 帧", variable=self.capture_frames_var).grid(row=0, column=0, sticky="w") ttk.Label(toolbar, text=f"最多保留 {MAX_LOG_LINES} 行", background="#ffffff", foreground="#61758a").grid(row=0, column=1, padx=12) ttk.Button(toolbar, text="导出", command=self._export_log).grid(row=0, column=2, padx=(0, 6)) ttk.Button(toolbar, text="清空", command=self._clear_log).grid(row=0, column=3) text_frame = ttk.Frame(parent, style="Surface.TFrame") text_frame.grid(row=1, column=0, sticky="nsew") text_frame.rowconfigure(0, weight=1) text_frame.columnconfigure(0, weight=1) self.log_text = tk.Text(text_frame, wrap="none", state="disabled", background="#0e1b28", foreground="#dce7f0", insertbackground="#ffffff", font=("Cascadia Mono", 9), padx=10, pady=8, relief="flat") y_scroll = ttk.Scrollbar(text_frame, orient="vertical", command=self.log_text.yview) x_scroll = ttk.Scrollbar(text_frame, orient="horizontal", command=self.log_text.xview) self.log_text.configure(yscrollcommand=y_scroll.set, xscrollcommand=x_scroll.set) self.log_text.grid(row=0, column=0, sticky="nsew") y_scroll.grid(row=0, column=1, sticky="ns") x_scroll.grid(row=1, column=0, sticky="ew") def _refresh_ports(self) -> None: if list_ports is None: messagebox.showerror("缺少依赖", "未安装 pyserial,请先运行 start_plsr_test.bat。") return current = self.port_var.get() ports = sorted((item.device for item in list_ports.comports()), key=str.upper) self.port_combo["values"] = ports if current in ports: self.port_var.set(current) elif "COM9" in ports: self.port_var.set("COM9") elif ports: self.port_var.set(ports[0]) else: self.port_var.set("") self.operation_var.set(f"检测到 {len(ports)} 个串口" if ports else "未检测到串口,请检查 USB 转串口连接") def _toggle_connection(self) -> None: if self.job_active: return if self.connected: self.auto_phase = None self._submit("disconnect", purpose="disconnect") return try: port = self.port_var.get().strip() if not port: raise ValueError("请先选择串口") baud = int(self.baud_var.get()) slave = int(self.slave_var.get()) if not 1 <= slave <= 247: raise ValueError("站号必须为 1–247") except ValueError as exc: messagebox.showerror("连接参数错误", str(exc)) return self.connection_var.set("连接中…") self.operation_var.set(f"正在连接 {port},{baud} / 8-E-1 / 站号 {slave}") self._submit("connect", purpose="connect", port=port, baud=baud, slave=slave, protocol=self.protocol_var.get()) def _submit(self, name: str, purpose: str = "", **data: Any) -> bool: job = {"name": name, "purpose": purpose, **data} if self.job_active: if self.poll_active and purpose != "poll" and self.deferred_job is None: self.deferred_job = job self._update_controls() return True return False if name not in ("connect", "disconnect") and not self.connected: messagebox.showwarning("设备未连接", "请先连接设备。") return False self._start_job(job) return True def _start_job(self, job: dict[str, Any]) -> None: self.job_active = True self.poll_active = job["name"] == "snapshot" and job.get("purpose") == "poll" self.jobs.put(job) if not self.poll_active: self._update_controls() def _start_deferred_job(self) -> None: if self.deferred_job is None or self.job_active: return job = self.deferred_job self.deferred_job = None self._start_job(job) def _parse_outputs(self) -> tuple[int, int]: if self.protocol_var.get() == MAP_SPEC: pulse = int(self.pulse_output_var.get().removeprefix("Y")) direction = int(self.direction_output_var.get().removeprefix("Y")) - 12 if not 0 <= pulse <= 3 or not 0 <= direction <= 3: raise ValueError("需求版输出必须为 Y0..Y3 与 Y12..Y15") else: pulse = int(self.pulse_output_var.get().removeprefix("Q")) direction = int(self.direction_output_var.get().removeprefix("Q")) if pulse == direction: raise ValueError("脉冲点与方向点不能相同") return pulse, direction def _parse_motion(self) -> tuple[int, int, int]: pulses = int(self.pulses_var.get()) frequency = int(self.frequency_var.get()) direction = int(self.direction_var.get()) if self.protocol_var.get() == MAP_SPEC: if pulses == 0 or not -2147483648 <= pulses <= 2147483647: raise ValueError("需求版脉冲个数必须为 -2147483648..2147483647 且不能为 0") validate_frequency(frequency) else: if not 1 <= pulses <= 0xFFFFFFFF: raise ValueError("legacy 目标脉冲数必须为 1–4294967295") if not 1 <= frequency <= 1000: raise ValueError("legacy 频率必须为 1–1000 Hz") return pulses, frequency, direction def _segment_from_editor(self) -> SegmentParameters: wait_values = {"WAIT时间": 0, "WAIT信号": 1, "ACT时间": 2, "EXT信号": 3, "EXT或完成": 4} segment = SegmentParameters( frequency_hz=int(self.segment_frequency_var.get()), pulses=int(self.segment_pulses_var.get()), wait_condition=wait_values[self.segment_wait_var.get()], wait_time_ms=int(self.segment_wait_time_var.get()), act_time_ms=int(self.segment_act_time_var.get()), jump_segment=int(self.segment_jump_var.get()), ) segment.validate(int(self.segment_index_var.get())) return segment def _apply_segment_editor(self) -> None: try: index = int(self.segment_index_var.get()) self.segment_models[index - 1] = self._segment_from_editor() except (KeyError, ValueError) as exc: messagebox.showerror("段参数错误", str(exc)) return self._refresh_segment_tree(select=index) self.operation_var.set(f"第 {index} 段参数已应用到待写配置") def _select_segment(self, _event: object = None) -> None: selection = self.segment_tree.selection() if not selection: return index = int(self.segment_tree.item(selection[0], "values")[0]) self._load_segment_editor(index) def _load_segment_editor(self, index: int) -> None: wait_names = ("WAIT时间", "WAIT信号", "ACT时间", "EXT信号", "EXT或完成") segment = self.segment_models[index - 1] self.segment_index_var.set(str(index)) self.segment_frequency_var.set(str(segment.frequency_hz)) self.segment_pulses_var.set(str(segment.pulses)) self.segment_wait_var.set(wait_names[segment.wait_condition] if 0 <= segment.wait_condition < 5 else "WAIT时间") self.segment_wait_time_var.set(str(segment.wait_time_ms)) self.segment_act_time_var.set(str(segment.act_time_ms)) self.segment_jump_var.set(str(segment.jump_segment)) def _refresh_segment_tree(self, select: int = 1) -> None: wait_names = ("WAIT时间", "WAIT信号", "ACT时间", "EXT信号", "EXT或完成") self.segment_tree.delete(*self.segment_tree.get_children()) selected = None for index, segment in enumerate(self.segment_models, 1): item = self.segment_tree.insert("", "end", values=(index, segment.frequency_hz, segment.pulses, wait_names[segment.wait_condition] if 0 <= segment.wait_condition < 5 else segment.wait_condition, segment.wait_time_ms, segment.act_time_ms, segment.jump_segment)) if index == select: selected = item if selected: self.segment_tree.selection_set(selected) self.segment_tree.focus(selected) def _configuration_from_ui(self) -> PlsrConfiguration: self.segment_models[int(self.segment_index_var.get()) - 1] = self._segment_from_editor() cfg = PlsrConfiguration( pulse_output=self._parse_outputs()[0], direction_output=self._parse_outputs()[1], wait_input=0 if self.wait_input_var.get() == "X4" else 1, ext_input=0 if self.ext_input_var.get() == "X4" else 1, send_mode=0 if self.send_mode_var.get() == "完成模式" else 1, direction_delay_ms=int(self.direction_delay_var.get()), direction_logic=0 if self.direction_logic_var.get() == "正逻辑" else 1, accel_mode={"直线": 0, "S曲线": 1, "正弦曲线": 2}[self.accel_mode_var.get()], run_mode=0 if self.run_mode_var.get() == "相对" else 1, segment_count=int(self.segment_count_var.get()), start_segment=int(self.start_segment_var.get()), default_speed_hz=int(self.default_speed_var.get()), start_speed_hz=int(self.start_speed_var.get()), end_speed_hz=int(self.end_speed_var.get()), accel_time_ms=int(self.accel_time_var.get()), decel_time_ms=int(self.decel_time_var.get()), segments=list(self.segment_models), ) cfg.validate() return cfg def _apply_configuration(self, cfg: PlsrConfiguration) -> None: self.pulse_output_var.set(f"Y{cfg.pulse_output}") self.direction_output_var.set(f"Y{cfg.direction_output + 12}") self.wait_input_var.set(f"X{cfg.wait_input + 4}") self.ext_input_var.set(f"X{cfg.ext_input + 4}") self.send_mode_var.set(("完成模式", "后续模式")[cfg.send_mode]) self.direction_delay_var.set(str(cfg.direction_delay_ms)) self.direction_logic_var.set(("正逻辑", "负逻辑")[cfg.direction_logic]) self.accel_mode_var.set(("直线", "S曲线", "正弦曲线")[cfg.accel_mode]) self.run_mode_var.set(("相对", "绝对")[cfg.run_mode]) self.segment_count_var.set(str(cfg.segment_count)) self.start_segment_var.set(str(cfg.start_segment)) self.default_speed_var.set(str(cfg.default_speed_hz)) self.start_speed_var.set(str(cfg.start_speed_hz)) self.end_speed_var.set(str(cfg.end_speed_hz)) self.accel_time_var.set(str(cfg.accel_time_ms)) self.decel_time_var.set(str(cfg.decel_time_ms)) self.segment_models = list(cfg.segments) self._refresh_segment_tree(select=cfg.start_segment) self._load_segment_editor(cfg.start_segment) def _read_full_config(self) -> None: if self.protocol_var.get() != MAP_SPEC: messagebox.showwarning("映射不支持", "完整参数页仅适用于 spec 参数表映射。") return self.operation_var.set("正在读取系统参数和 10 段路径") self._submit("read_config", purpose="read_config") def _write_full_config(self) -> None: if self.protocol_var.get() != MAP_SPEC: messagebox.showwarning("映射不支持", "完整参数页仅适用于 spec 参数表映射。") return try: config = self._configuration_from_ui() except (KeyError, ValueError) as exc: messagebox.showerror("完整参数校验失败", str(exc)) return self._refresh_segment_tree(select=config.start_segment) self.operation_var.set(f"正在写入完整配置:{config.segment_count} 段,从第 {config.start_segment} 段执行") self._submit("write_config", purpose="write_config", config=config) def _confirm_motion(self, title: str, detail: str) -> bool: pulse = self.pulse_output_var.get() direction = self.direction_output_var.get() return messagebox.askyesno( title, "即将产生真实硬件输出。\n\n" f"脉冲点:{pulse}\n方向点:{direction}\n{detail}\n\n" "请确认机械负载已断开,当前只连接测试灯或示波器,且现场允许输出。", icon="warning", ) def _save_outputs(self) -> None: try: pulse, direction = self._parse_outputs() except ValueError as exc: messagebox.showerror("Q 点设置错误", str(exc)) return output_text = (f"脉冲 Y{pulse},方向 Y{direction + 12}" if self.protocol_var.get() == MAP_SPEC else f"脉冲 Q{pulse},方向 Q{direction}") self.operation_var.set(f"正在写入输出映射:{output_text}") self._submit("configure", purpose="save_outputs", pulse_output=pulse, direction_output=direction) def _start_motion(self) -> None: self._start_or_restart_motion(False) def _restart_motion(self) -> None: self._start_or_restart_motion(True) def _apply_running_frequency(self) -> None: try: frequency = int(self.frequency_var.get()) validate_frequency(frequency) except ValueError as exc: messagebox.showerror("频率参数错误", str(exc)) return self.operation_var.set(f"正在将运行频率修改为 {frequency} Hz") self._submit( "frequency", purpose="update_frequency", frequency=frequency ) def _start_or_restart_motion(self, restart: bool) -> None: try: pulses, frequency, direction = self._parse_motion() pulse, direction_output = self._parse_outputs() if self.last_snapshot is None or ( pulse != self.last_snapshot.pulse_output or direction_output != self.last_snapshot.direction_output ): raise ValueError("所选 Q 点尚未写入设备,请先点击“写入并保存”") except ValueError as exc: messagebox.showerror("运动参数错误", str(exc)) return direction_text = "反向" if direction else "正向" action = "重新运行" if restart else "启动/继续" if not self._confirm_motion(f"确认{action}脉冲", f"{direction_text},{pulses} 脉冲,{frequency} Hz"): return self.auto_phase = None plan = (abs(pulses), frequency, direction) continuing = (not restart and self.last_snapshot is not None and self.last_snapshot.state == 6 and self.motion_plan == plan) if not continuing: self.run_baseline = self.last_snapshot.total_pulses if self.last_snapshot else 0 self.run_target = abs(pulses) self.motion_plan = plan self._reset_progress() self.operation_var.set(f"正在{action}:{direction_text} / {pulses} 脉冲 / {frequency} Hz") self._submit("start", purpose="normal_restart" if restart else "normal_start", pulses=pulses, frequency=frequency, direction=direction, restart=restart) def _start_config_sequence(self, restart: bool) -> None: if self.protocol_var.get() != MAP_SPEC: messagebox.showwarning("映射不支持", "完整参数与多段启动仅适用于 spec 参数表映射。") return try: config = self._configuration_from_ui() except (KeyError, ValueError) as exc: messagebox.showerror("完整参数校验失败", str(exc)) return action = "重新启动" if restart else "启动/继续" detail = ( f"{action}完整多段任务\n" f"起始段:{config.start_segment} / 共 {config.segment_count} 段\n" f"发送模式:{'完成模式' if config.send_mode == 0 else '后续模式'}\n" "设备将先写入当前页全部参数,再执行启动命令。" ) if not self._confirm_motion(f"确认{action}多段脉冲", detail): return self._refresh_segment_tree(select=config.start_segment) self.auto_phase = None purpose = "config_restart" if restart else "config_start" self.operation_var.set(f"正在写入并{action}完整多段任务") self._submit( "write_config_command", purpose=purpose, config=config, command=COMMAND_RESTART if restart else COMMAND_START, ) def _start_lamp_test(self) -> None: try: pulse, direction = self._parse_outputs() except ValueError as exc: messagebox.showerror("Q 点设置错误", str(exc)) return if not self._confirm_motion("确认低速正反转灯测", "正向 6 脉冲 / 2 Hz,然后反向 6 脉冲 / 2 Hz"): return self.auto_phase = "config" self.lamp_test_baseline = self.last_snapshot.total_pulses if self.last_snapshot else 0 self.run_baseline = self.lamp_test_baseline self.run_target = 12 self._reset_progress() self.operation_var.set("灯测 1/4:保存 Q 点配置") self._submit("configure", purpose="auto_config", pulse_output=pulse, direction_output=direction) def _send_command(self, command: int, purpose: str) -> None: if purpose in ("stop", "immediate_stop"): self.auto_phase = None labels = { "stop": "正在发送受控停止命令", "immediate_stop": "正在发送立即停止命令", "reset": "正在复位状态", "clear": "正在清零累计脉冲", } self.operation_var.set(labels[purpose]) self._submit("command", purpose=purpose, command=command) def _drain_events(self) -> None: try: while True: event = self.events.get_nowait() if event["type"] == "log": if self.capture_frames_var.get(): self._append_log(event["line"]) elif event["type"] == "result": self._handle_result(event) elif event["type"] == "error": self._handle_error(event) except queue.Empty: pass if not self.closing: self.after(50, self._drain_events) def _handle_result(self, event: dict[str, Any]) -> None: self.job_active = False self.poll_active = False self.communication_errors = 0 name = event["name"] purpose = event["purpose"] result = event["result"] if name == "connect": self.connected = True self.connection_var.set("已连接") self.connection_label.configure(style="StatusOn.TLabel") self.connect_button.configure(text="断开连接") self.operation_var.set(f"连接成功:{self.port_var.get()} / {self.baud_var.get()} / 8-E-1 / 站号 {self.slave_var.get()}") self._append_log(f"{time.strftime('%H:%M:%S')} INFO 已连接 {self.port_var.get()}") self._apply_snapshot(result) self._sync_output_selectors(result) elif name == "disconnect": self._set_disconnected("已断开连接") elif name == "raw": self._show_raw_registers(result) self.operation_var.set("寄存器读取完成") elif isinstance(result, PlsrConfiguration): self._apply_configuration(result) self.operation_var.set("完整参数已从设备回读" if purpose == "read_config" else "完整参数写入并回读校验完成") elif isinstance(result, Snapshot): self._apply_snapshot(result) if name == "configure": self._sync_output_selectors(result) if purpose == "save_outputs": output_text = (f"脉冲 Y{result.pulse_output},方向 Y{result.direction_output + 12}" if result.protocol == MAP_SPEC else f"脉冲 Q{result.pulse_output},方向 Q{result.direction_output}") self.operation_var.set(f"输出映射已写入:{output_text}") elif purpose == "auto_config": self.auto_phase = "forward" self.run_baseline = result.total_pulses self.lamp_test_baseline = result.total_pulses self.operation_var.set("灯测 2/3:正向 6 脉冲 / 2 Hz") self._submit("start", purpose="auto_forward", pulses=6, frequency=2, direction=0, restart=True) elif purpose == "auto_forward": self.operation_var.set("灯测正向运行中:观察脉冲灯亮灭") elif purpose == "auto_reverse": self.operation_var.set("灯测反向运行中:脉冲灯仍应亮灭,方向点电平应改变") elif purpose == "normal_start": self.operation_var.set("运动已启动/继续,正在轮询状态") elif purpose == "normal_restart": self.operation_var.set("运动已从本次任务起点重新运行,正在轮询状态") elif purpose == "config_start": self.operation_var.set("完整多段参数已写入,任务已启动/继续") elif purpose == "config_restart": self.operation_var.set("完整多段参数已写入,任务已重新启动") elif purpose == "update_frequency": self.operation_var.set( f"运行频率已修改为 {result.current_frequency} Hz" ) elif purpose == "clear": self.run_baseline = result.total_pulses self.run_target = 0 self._reset_progress() self.operation_var.set("累计脉冲已清零") elif purpose == "reset": self.operation_var.set("状态已复位") elif purpose == "stop": self.operation_var.set("受控停止命令已执行") elif purpose == "immediate_stop": self.operation_var.set("立即停止命令已执行") elif name == "snapshot": if purpose == "manual": self.operation_var.set("状态读取完成") self._advance_lamp_test(result) self._start_deferred_job() self._update_controls() def _handle_error(self, event: dict[str, Any]) -> None: self.job_active = False self.poll_active = False name = event["name"] error = event["error"] purpose = event["purpose"] self._append_log(f"{time.strftime('%H:%M:%S')} ERROR {error}") self.operation_var.set(f"通信/操作失败:{error}") if name == "connect": self._set_disconnected("连接失败") messagebox.showerror("连接失败", error) else: if purpose.startswith("auto_"): self.auto_phase = None messagebox.showerror("灯测中止", error) if name == "snapshot": self.communication_errors += 1 if self.communication_errors >= 3: self._set_disconnected("通信中断") messagebox.showerror("通信中断", "连续 3 次读取失败,已停止自动轮询。\n请检查串口占用、接线和设备供电后重新连接。") self._start_deferred_job() self._update_controls() def _apply_snapshot(self, snapshot: Snapshot) -> None: self.last_snapshot = snapshot state = STATE_TEXT.get(snapshot.state, f"未知({snapshot.state})") self.state_var.set(f"{state}({snapshot.state})") flag_names = [] if snapshot.flags & 0x0001: flag_names.append("运行") if snapshot.flags & 0x0002: flag_names.append("完成") if snapshot.flags & 0x0004: flag_names.append("错误") suffix = " / ".join(flag_names) if flag_names else "无" self.flags_var.set(f"0x{snapshot.flags:04X} · {suffix}") self.current_frequency_var.set(f"{snapshot.current_frequency} Hz") self.total_pulses_var.set(f"{snapshot.total_pulses:,}") self.stop_reason_var.set(STOP_REASON_TEXT.get(snapshot.stop_reason, str(snapshot.stop_reason))) self.error_var.set(ERROR_TEXT.get(snapshot.last_error, f"{snapshot.last_error} · 未知错误")) self.saved_outputs_var.set((f"脉冲 Y{snapshot.pulse_output} / 方向 Y{snapshot.direction_output + 12}") if snapshot.protocol == MAP_SPEC else f"脉冲 Q{snapshot.pulse_output} / 方向 Q{snapshot.direction_output}") self._update_progress(snapshot) def _sync_output_selectors(self, snapshot: Snapshot) -> None: if snapshot.protocol == MAP_SPEC: self.pulse_output_var.set(f"Y{snapshot.pulse_output}") self.direction_output_var.set(f"Y{snapshot.direction_output + 12}") else: self.pulse_output_var.set(f"Q{snapshot.pulse_output}") self.direction_output_var.set(f"Q{snapshot.direction_output}") def _advance_lamp_test(self, snapshot: Snapshot) -> None: if self.auto_phase == "forward" and snapshot.state in (5, 6, 7): actual = snapshot.total_pulses - self.run_baseline if snapshot.state != 5 or actual != 6: self.auto_phase = None messagebox.showerror("正向灯测失败", f"状态:{STATE_TEXT.get(snapshot.state, snapshot.state)}\n本阶段累计:{actual},期望:6") return self.auto_phase = "reverse" self.run_baseline = snapshot.total_pulses self.operation_var.set("灯测 3/3:反向 6 脉冲 / 2 Hz") self._submit("start", purpose="auto_reverse", pulses=6, frequency=2, direction=1, restart=True) elif self.auto_phase == "reverse" and snapshot.state in (5, 6, 7): actual = abs(snapshot.total_pulses - self.run_baseline) self.auto_phase = None if snapshot.state == 5 and actual == 6 and snapshot.total_pulses == self.lamp_test_baseline: self.operation_var.set("低速正反转灯测通过:正向和反向各 6 脉冲") messagebox.showinfo("灯测完成", "软件计数验证通过:正向 6 脉冲,反向 6 脉冲。\n请同时确认反向时脉冲灯仍亮灭,方向点电平已改变。") else: messagebox.showerror("反向灯测失败", f"状态:{STATE_TEXT.get(snapshot.state, snapshot.state)}\n反向阶段脉冲:{actual},期望:6\n灯测前累计:{self.lamp_test_baseline},灯测后累计:{snapshot.total_pulses}") def _update_progress(self, snapshot: Snapshot) -> None: if self.run_target <= 0: return done = abs(snapshot.total_pulses - self.run_baseline) percent = min(100.0, done * 100.0 / self.run_target) self.progress.configure(value=percent) self.progress_text_var.set(f"{min(done, self.run_target):,} / {self.run_target:,}({percent:.0f}%)") def _reset_progress(self) -> None: self.progress.configure(value=0) self.progress_text_var.set(f"0 / {self.run_target:,}" if self.run_target else "0 / 0") def _show_raw_registers(self, values: dict[str, list[int]]) -> None: self.register_tree.delete(*self.register_tree.get_children()) if "status" in values: for address, value in enumerate(values["status"]): self.register_tree.insert("", "end", values=(address, f"0x{value:04X}", value, REGISTER_NAMES.get(address, "保留/未使用"))) for offset, value in enumerate(values["control"]): address = 100 + offset self.register_tree.insert("", "end", values=(address, f"0x{value:04X}", value, REGISTER_NAMES.get(address, "保留/未使用"))) return for offset, value in enumerate(values["config"]): address = 0x1000 + offset self.register_tree.insert("", "end", values=(f"0x{address:04X}", f"0x{value:04X}", value, SPEC_REGISTER_NAMES.get(address, "配置/保留"))) for index, segment in enumerate(values["segments"], 1): base = REG_SEGMENT_BASE + (index - 1) * 0x10 for offset, value in enumerate(segment): address = base + offset self.register_tree.insert("", "end", values=(f"0x{address:04X}", f"0x{value:04X}", value, f"第{index}段 / 偏移 {offset}")) for offset, value in enumerate(values["monitor"]): address = REG_MONITOR_TOTAL_PULSES + offset self.register_tree.insert("", "end", values=(f"0x{address:04X}", f"0x{value:04X}", value, SPEC_REGISTER_NAMES.get(address, "监控/保留"))) value = values["control"][0] self.register_tree.insert("", "end", values=("0x3000", f"0x{value:04X}", value, SPEC_REGISTER_NAMES[0x3000])) def _poll_tick(self) -> None: if not self.closing and self.connected and not self.job_active: self._submit("snapshot", purpose="poll") if not self.closing: self.after(POLL_INTERVAL_MS, self._poll_tick) def _update_controls(self) -> None: ui_busy = (self.job_active and not self.poll_active) or self.deferred_job is not None connected_idle = self.connected and not ui_busy device_running = self.last_snapshot is not None and self.last_snapshot.state in (1, 2, 3, 4) can_start = connected_idle and not device_running can_stop = connected_idle and device_running self._set_widget_state(self.connect_button, "disabled" if ui_busy else "normal") serial_state = "disabled" if self.connected or ui_busy else "readonly" self._set_widget_state(self.port_combo, serial_state) self._set_widget_state(self.baud_combo, serial_state) self._set_widget_state(self.slave_spin, "disabled" if self.connected or ui_busy else "normal") self._set_widget_state(self.refresh_button, "disabled" if self.connected or ui_busy else "normal") for widget in (self.save_outputs_button, self.read_now_button, self.raw_button, self.reset_button, self.clear_button): self._set_widget_state(widget, "normal" if connected_idle and not device_running else "disabled") for widget in (self.read_config_button, self.write_config_button, self.apply_segment_button): allowed = connected_idle and not device_running and self.protocol_var.get() == MAP_SPEC self._set_widget_state(widget, "normal" if allowed else "disabled") config_start_allowed = connected_idle and not device_running and self.protocol_var.get() == MAP_SPEC config_stop_allowed = connected_idle and device_running and self.protocol_var.get() == MAP_SPEC config_idle_allowed = connected_idle and not device_running and self.protocol_var.get() == MAP_SPEC for widget in (self.config_start_button, self.config_restart_button): self._set_widget_state(widget, "normal" if config_start_allowed else "disabled") self._set_widget_state(self.config_stop_button, "normal" if config_stop_allowed else "disabled") for widget in (self.config_emergency_button,): self._set_widget_state(widget, "normal" if config_stop_allowed else "disabled") for widget in (self.config_reset_button, self.config_clear_button): self._set_widget_state(widget, "normal" if config_idle_allowed else "disabled") self._set_widget_state(self.start_button, "normal" if can_start else "disabled") self._set_widget_state(self.lamp_test_button, "normal" if can_start else "disabled") self._set_widget_state(self.stop_button, "normal" if can_stop else "disabled") self._set_widget_state(self.emergency_button, "normal" if can_start else "disabled") config_state = "readonly" if connected_idle and not device_running else "disabled" self._set_widget_state(self.pulse_output_combo, config_state) self._set_widget_state(self.direction_output_combo, config_state) motion_state = "normal" if can_start else "disabled" self._set_widget_state(self.pulses_spin, motion_state) self._set_widget_state( self.frequency_spin, "normal" if connected_idle else "disabled" ) self._set_widget_state( self.apply_frequency_button, "normal" if connected_idle and device_running else "disabled", ) self._set_widget_state(self.forward_radio, motion_state) self._set_widget_state(self.reverse_radio, motion_state) @staticmethod def _set_widget_state(widget: ttk.Widget, state: str) -> None: if str(widget.cget("state")) != state: widget.configure(state=state) def _set_disconnected(self, message: str) -> None: self.connected = False self.job_active = False self.poll_active = False self.deferred_job = None self.auto_phase = None self.last_snapshot = None self.connection_var.set(message) self.connection_label.configure(style="StatusOff.TLabel") self.connect_button.configure(text="连接设备") self.operation_var.set(message) self._update_controls() def _append_log(self, line: str) -> None: self.log_text.configure(state="normal") self.log_text.insert("end", line + "\n") self.log_line_count += 1 if self.log_line_count > MAX_LOG_LINES: remove_count = min(100, self.log_line_count - MAX_LOG_LINES) self.log_text.delete("1.0", f"{remove_count + 1}.0") self.log_line_count -= remove_count self.log_text.see("end") self.log_text.configure(state="disabled") def _clear_log(self) -> None: self.log_text.configure(state="normal") self.log_text.delete("1.0", "end") self.log_text.configure(state="disabled") self.log_line_count = 0 def _export_log(self) -> None: path = filedialog.asksaveasfilename( title="导出通信日志", defaultextension=".log", filetypes=(("日志文件", "*.log"), ("文本文件", "*.txt"), ("所有文件", "*.*")), initialfile=f"plsr-{time.strftime('%Y%m%d-%H%M%S')}.log", ) if not path: return Path(path).write_text(self.log_text.get("1.0", "end-1c"), encoding="utf-8") self.operation_var.set(f"日志已导出:{path}") def _on_close(self) -> None: if self.closing: return self.closing = True self.jobs.put({"name": "shutdown"}) self.after(150, self.destroy) def main() -> None: app = PlsrGui() app.mainloop() if __name__ == "__main__": main()