Non puoi selezionare più di 25 argomenti Gli argomenti devono iniziare con una lettera o un numero, possono includere trattini ('-') e possono essere lunghi fino a 35 caratteri.
 
 
 
 
 
 

1309 righe
68 KiB

  1. #!/usr/bin/env python3
  2. """Tkinter upper-computer test application for the PLSR Modbus firmware."""
  3. from __future__ import annotations
  4. import queue
  5. import threading
  6. import time
  7. import tkinter as tk
  8. from pathlib import Path
  9. from tkinter import filedialog, messagebox, ttk
  10. from typing import Any, Optional
  11. from plsr_test import (
  12. COMMAND_CLEAR_COUNT,
  13. COMMAND_IMMEDIATE_STOP,
  14. COMMAND_RESTART,
  15. COMMAND_RESET,
  16. COMMAND_STOP,
  17. CommunicationError,
  18. ModbusRtuClient,
  19. PlsrError,
  20. Snapshot,
  21. MAP_SPEC,
  22. MAP_LEGACY,
  23. REG_SEGMENT_BASE,
  24. REG_MONITOR_TOTAL_PULSES,
  25. REG_CONTROL,
  26. PlsrConfiguration,
  27. SegmentParameters,
  28. validate_frequency,
  29. list_ports,
  30. )
  31. APP_TITLE = "PLSR 单轴脉冲测试台"
  32. POLL_INTERVAL_MS = 500
  33. MAX_LOG_LINES = 800
  34. STATE_TEXT = {
  35. 0: "空闲",
  36. 1: "准备",
  37. 2: "运行中",
  38. 3: "等待",
  39. 4: "减速中",
  40. 5: "完成",
  41. 6: "已停止",
  42. 7: "错误",
  43. }
  44. STOP_REASON_TEXT = {
  45. 0: "0 · 无",
  46. 1: "1 · 受控停止",
  47. 2: "2 · 立即停止",
  48. }
  49. ERROR_TEXT = {
  50. 0: "0 · 无错误",
  51. 1: "1 · 参数无效",
  52. 2: "2 · 设备忙",
  53. 3: "3 · 定时器资源错误",
  54. }
  55. REGISTER_NAMES = {
  56. 0: "运行状态",
  57. 1: "状态标志",
  58. 3: "当前频率 Hz",
  59. 5: "停止原因",
  60. 6: "最后错误",
  61. 10: "累计脉冲高字",
  62. 11: "累计脉冲低字",
  63. 100: "命令",
  64. 102: "目标脉冲高字",
  65. 103: "目标脉冲低字",
  66. 104: "设定频率 Hz",
  67. 105: "方向 0正/1反",
  68. 106: "脉冲输出 Q 点",
  69. 107: "方向输出 Q 点",
  70. }
  71. SPEC_REGISTER_NAMES = {
  72. 0x1000: "脉冲发送端子 Y0..Y3", 0x1001: "脉冲方向端子 Y12..Y15",
  73. 0x1002: "WAIT 信号 X4/X5", 0x1003: "EXT 信号 X4/X5",
  74. 0x1004: "发送模式", 0x1005: "方向延时 ms", 0x1006: "方向逻辑",
  75. 0x1007: "加减速模式", 0x1008: "运行模式", 0x1009: "总段数",
  76. 0x100A: "起始执行段", 0x100B: "默认速度高字", 0x100C: "默认速度低字",
  77. 0x100D: "起始速度高字", 0x100E: "起始速度低字", 0x1010: "终止速度高字",
  78. 0x1011: "终止速度低字", 0x1012: "加速时间 ms", 0x1013: "减速时间 ms",
  79. 0x2000: "累计脉冲高字", 0x2001: "累计脉冲低字", 0x2002: "频率高字",
  80. 0x2003: "频率低字", 0x2004: "运行状态", 0x2005: "当前执行段",
  81. 0x2006: "错误码", 0x2007: "停止原因",
  82. 0x3000: "控制 bit0启动/bit1停止/bit2清零/bit3重启/bit4复位/bit5立即停止",
  83. }
  84. class GuiModbusClient(ModbusRtuClient):
  85. """Send raw-frame messages to Tk through a thread-safe queue."""
  86. def __init__(self, *args: Any, event_queue: queue.Queue, **kwargs: Any) -> None:
  87. super().__init__(*args, **kwargs)
  88. self._event_queue = event_queue
  89. def _log_frame(self, prefix: str, frame: bytes) -> None:
  90. now = time.time()
  91. timestamp = time.strftime("%H:%M:%S", time.localtime(now)) + f".{int(now % 1 * 1000):03d}"
  92. self._event_queue.put(
  93. {
  94. "type": "log",
  95. "line": f"{timestamp} {prefix} {frame.hex(' ').upper()}",
  96. }
  97. )
  98. class SerialWorker(threading.Thread):
  99. """Own the serial object so only one request can be active at a time."""
  100. def __init__(self, jobs: queue.Queue, events: queue.Queue) -> None:
  101. super().__init__(name="plsr-serial-worker", daemon=True)
  102. self.jobs = jobs
  103. self.events = events
  104. self.client: Optional[GuiModbusClient] = None
  105. def _require_client(self) -> GuiModbusClient:
  106. if self.client is None:
  107. raise CommunicationError("设备尚未连接")
  108. return self.client
  109. def run(self) -> None:
  110. while True:
  111. job = self.jobs.get()
  112. name = job["name"]
  113. if name == "shutdown":
  114. if self.client is not None:
  115. self.client.close()
  116. return
  117. try:
  118. result: Any = None
  119. if name == "connect":
  120. if self.client is not None:
  121. self.client.close()
  122. self.client = GuiModbusClient(
  123. port=job["port"],
  124. baud_rate=job["baud"],
  125. slave=job["slave"],
  126. timeout=0.6,
  127. retries=2,
  128. verbose=False,
  129. event_queue=self.events,
  130. protocol=job.get("protocol", MAP_SPEC),
  131. )
  132. try:
  133. self.client.open()
  134. result = self.client.read_snapshot()
  135. except Exception:
  136. self.client.close()
  137. self.client = None
  138. raise
  139. elif name == "disconnect":
  140. if self.client is not None:
  141. self.client.close()
  142. self.client = None
  143. elif name == "snapshot":
  144. result = self._require_client().read_snapshot()
  145. elif name == "read_config":
  146. result = self._require_client().read_configuration()
  147. elif name == "write_config":
  148. client = self._require_client()
  149. client.write_configuration(job["config"])
  150. result = client.read_configuration()
  151. elif name == "write_config_command":
  152. client = self._require_client()
  153. client.write_configuration(job["config"])
  154. client.command(job["command"])
  155. result = client.read_snapshot()
  156. elif name == "raw":
  157. client = self._require_client()
  158. if client.protocol == MAP_SPEC:
  159. result = {"config": client.read_holding(0x1000, 0x14),
  160. "segments": [client.read_holding(REG_SEGMENT_BASE + i * 0x10, 8) for i in range(10)],
  161. "monitor": client.read_holding(REG_MONITOR_TOTAL_PULSES, 8),
  162. "control": client.read_holding(REG_CONTROL, 1)}
  163. else:
  164. result = {"status": client.read_holding(0, 32), "control": client.read_holding(100, 8)}
  165. elif name == "configure":
  166. result = self._require_client().configure_outputs(
  167. job["pulse_output"], job["direction_output"], save=True
  168. )
  169. elif name == "start":
  170. client = self._require_client()
  171. client.start_motion(job["pulses"], job["frequency"],
  172. job["direction"], job.get("restart", False))
  173. result = client.read_snapshot()
  174. elif name == "frequency":
  175. result = self._require_client().set_running_frequency(
  176. job["frequency"]
  177. )
  178. elif name == "command":
  179. client = self._require_client()
  180. client.command(job["command"])
  181. result = client.read_snapshot()
  182. else:
  183. raise ValueError(f"未知后台任务: {name}")
  184. self.events.put(
  185. {
  186. "type": "result",
  187. "name": name,
  188. "purpose": job.get("purpose", ""),
  189. "result": result,
  190. }
  191. )
  192. except Exception as exc:
  193. self.events.put(
  194. {
  195. "type": "error",
  196. "name": name,
  197. "purpose": job.get("purpose", ""),
  198. "error": str(exc),
  199. }
  200. )
  201. class PlsrGui(tk.Tk):
  202. def __init__(self) -> None:
  203. super().__init__()
  204. self.title(f"{APP_TITLE} - Python")
  205. self.geometry("1120x820")
  206. self.minsize(960, 680)
  207. self.configure(background="#f2f5f8")
  208. self.jobs: queue.Queue = queue.Queue()
  209. self.events: queue.Queue = queue.Queue()
  210. self.worker = SerialWorker(self.jobs, self.events)
  211. self.worker.start()
  212. self.connected = False
  213. self.job_active = False
  214. self.poll_active = False
  215. self.deferred_job: Optional[dict[str, Any]] = None
  216. self.last_snapshot: Optional[Snapshot] = None
  217. self.communication_errors = 0
  218. self.closing = False
  219. self.log_line_count = 0
  220. self.auto_phase: Optional[str] = None
  221. self.run_baseline = 0
  222. self.run_target = 0
  223. self.motion_plan: Optional[tuple[int, int, int]] = None
  224. self.lamp_test_baseline = 0
  225. self._create_variables()
  226. self._configure_style()
  227. self._build_ui()
  228. self._refresh_ports()
  229. self._update_controls()
  230. self.protocol("WM_DELETE_WINDOW", self._on_close)
  231. self.after(50, self._drain_events)
  232. self.after(POLL_INTERVAL_MS, self._poll_tick)
  233. def _create_variables(self) -> None:
  234. self.port_var = tk.StringVar(value="COM9")
  235. self.baud_var = tk.StringVar(value="115200")
  236. self.slave_var = tk.StringVar(value="1")
  237. self.connection_var = tk.StringVar(value="未连接")
  238. self.operation_var = tk.StringVar(value="等待连接")
  239. self.pulse_output_var = tk.StringVar(value="Y0")
  240. self.direction_output_var = tk.StringVar(value="Y12")
  241. self.pulses_var = tk.StringVar(value="10")
  242. self.frequency_var = tk.StringVar(value="2")
  243. self.direction_var = tk.IntVar(value=0)
  244. self.capture_frames_var = tk.BooleanVar(value=True)
  245. self.protocol_var = tk.StringVar(value=MAP_SPEC)
  246. self.wait_input_var = tk.StringVar(value="X4")
  247. self.ext_input_var = tk.StringVar(value="X4")
  248. self.send_mode_var = tk.StringVar(value="完成模式")
  249. self.direction_delay_var = tk.StringVar(value="0")
  250. self.direction_logic_var = tk.StringVar(value="正逻辑")
  251. self.accel_mode_var = tk.StringVar(value="直线")
  252. self.run_mode_var = tk.StringVar(value="相对")
  253. self.segment_count_var = tk.StringVar(value="1")
  254. self.start_segment_var = tk.StringVar(value="1")
  255. self.default_speed_var = tk.StringVar(value="1000")
  256. self.start_speed_var = tk.StringVar(value="1")
  257. self.end_speed_var = tk.StringVar(value="1")
  258. self.accel_time_var = tk.StringVar(value="0")
  259. self.decel_time_var = tk.StringVar(value="0")
  260. self.segment_index_var = tk.StringVar(value="1")
  261. self.segment_frequency_var = tk.StringVar(value="1000")
  262. self.segment_pulses_var = tk.StringVar(value="10")
  263. self.segment_wait_var = tk.StringVar(value="WAIT时间")
  264. self.segment_wait_time_var = tk.StringVar(value="0")
  265. self.segment_act_time_var = tk.StringVar(value="0")
  266. self.segment_jump_var = tk.StringVar(value="0")
  267. self.segment_models = [SegmentParameters() for _ in range(10)]
  268. self.state_var = tk.StringVar(value="--")
  269. self.flags_var = tk.StringVar(value="--")
  270. self.current_frequency_var = tk.StringVar(value="--")
  271. self.total_pulses_var = tk.StringVar(value="--")
  272. self.stop_reason_var = tk.StringVar(value="--")
  273. self.error_var = tk.StringVar(value="--")
  274. self.saved_outputs_var = tk.StringVar(value="--")
  275. self.progress_text_var = tk.StringVar(value="0 / 0")
  276. def _configure_style(self) -> None:
  277. style = ttk.Style(self)
  278. if "clam" in style.theme_names():
  279. style.theme_use("clam")
  280. default_font = ("Microsoft YaHei UI", 9)
  281. self.option_add("*Font", default_font)
  282. style.configure("TFrame", background="#f2f5f8")
  283. style.configure("Surface.TFrame", background="#ffffff")
  284. style.configure("TLabel", background="#ffffff", foreground="#20364d")
  285. style.configure("TCheckbutton", background="#ffffff", foreground="#20364d")
  286. style.configure("TRadiobutton", background="#ffffff", foreground="#20364d")
  287. style.configure("Header.TLabel", background="#f2f5f8", foreground="#123354", font=("Microsoft YaHei UI", 20, "bold"))
  288. style.configure("Subtitle.TLabel", background="#f2f5f8", foreground="#61758a")
  289. style.configure("Section.TLabel", background="#ffffff", foreground="#173b61", font=("Microsoft YaHei UI", 10, "bold"))
  290. style.configure("Value.TLabel", background="#ffffff", foreground="#102f4f", font=("Microsoft YaHei UI", 10, "bold"))
  291. style.configure("StatusOff.TLabel", background="#fdebea", foreground="#b42318", padding=(12, 7), font=("Microsoft YaHei UI", 9, "bold"))
  292. style.configure("StatusOn.TLabel", background="#e7f6ec", foreground="#167044", padding=(12, 7), font=("Microsoft YaHei UI", 9, "bold"))
  293. style.configure("Primary.TButton", foreground="#ffffff", background="#1f6fa8", padding=(10, 6), font=("Microsoft YaHei UI", 9, "bold"))
  294. style.map("Primary.TButton", background=[("active", "#185d8e"), ("disabled", "#aab7c2")])
  295. style.configure("Danger.TButton", foreground="#ffffff", background="#c93b3b", padding=(10, 6), font=("Microsoft YaHei UI", 9, "bold"))
  296. style.map("Danger.TButton", background=[("active", "#a92f2f"), ("disabled", "#c9b2b2")])
  297. style.configure("TLabelframe", background="#ffffff", padding=10)
  298. style.configure("TLabelframe.Label", background="#ffffff", foreground="#173b61", font=("Microsoft YaHei UI", 10, "bold"))
  299. style.configure("TNotebook", background="#f2f5f8", borderwidth=0)
  300. style.configure("TNotebook.Tab", padding=(16, 7))
  301. def _build_ui(self) -> None:
  302. outer = ttk.Frame(self, padding=(22, 14, 22, 12))
  303. outer.grid(row=0, column=0, sticky="nsew")
  304. self.rowconfigure(0, weight=1)
  305. self.columnconfigure(0, weight=1)
  306. outer.columnconfigure(0, weight=1)
  307. outer.rowconfigure(3, weight=1)
  308. header = ttk.Frame(outer)
  309. header.grid(row=0, column=0, sticky="ew", pady=(0, 10))
  310. header.columnconfigure(0, weight=1)
  311. ttk.Label(header, text=APP_TITLE, style="Header.TLabel").grid(row=0, column=0, sticky="w")
  312. ttk.Label(
  313. header,
  314. text="XDM-60T4-E / STM32 · Modbus RTU · 可配置并掉电保存 Q 点",
  315. style="Subtitle.TLabel",
  316. ).grid(row=1, column=0, sticky="w", pady=(2, 0))
  317. self.connection_label = ttk.Label(header, textvariable=self.connection_var, style="StatusOff.TLabel")
  318. self.connection_label.grid(row=0, column=1, rowspan=2, sticky="e")
  319. connection = ttk.LabelFrame(outer, text="串口连接")
  320. connection.grid(row=1, column=0, sticky="ew", pady=(0, 10))
  321. connection.columnconfigure(8, weight=1)
  322. ttk.Label(connection, text="端口").grid(row=0, column=0, padx=(0, 6))
  323. self.port_combo = ttk.Combobox(connection, textvariable=self.port_var, width=12, state="readonly")
  324. self.port_combo.grid(row=0, column=1, padx=(0, 8))
  325. self.refresh_button = ttk.Button(connection, text="刷新", command=self._refresh_ports, width=9)
  326. self.refresh_button.grid(row=0, column=2, padx=(0, 20))
  327. ttk.Label(connection, text="波特率").grid(row=0, column=3, padx=(0, 6))
  328. self.baud_combo = ttk.Combobox(connection, textvariable=self.baud_var, values=("9600", "19200", "38400", "57600", "115200"), width=10, state="readonly")
  329. self.baud_combo.grid(row=0, column=4, padx=(0, 18))
  330. ttk.Label(connection, text="格式 8-E-1").grid(row=0, column=5, padx=(0, 18))
  331. ttk.Label(connection, text="站号").grid(row=0, column=6, padx=(0, 6))
  332. self.slave_spin = ttk.Spinbox(connection, textvariable=self.slave_var, from_=1, to=247, width=7)
  333. self.slave_spin.grid(row=0, column=7)
  334. ttk.Label(connection, text="映射").grid(row=0, column=8, padx=(16, 6))
  335. self.protocol_combo = ttk.Combobox(connection, textvariable=self.protocol_var, values=(MAP_SPEC, MAP_LEGACY), width=9, state="readonly")
  336. self.protocol_combo.grid(row=0, column=9, padx=(0, 8))
  337. self.connect_button = ttk.Button(connection, text="连接设备", command=self._toggle_connection, style="Primary.TButton", width=14)
  338. self.connect_button.grid(row=0, column=10, sticky="e", padx=(8, 0))
  339. self.operation_label = ttk.Label(outer, textvariable=self.operation_var, style="Subtitle.TLabel")
  340. self.operation_label.grid(row=2, column=0, sticky="w", pady=(0, 6))
  341. notebook = ttk.Notebook(outer)
  342. notebook.grid(row=3, column=0, sticky="nsew")
  343. control_tab = ttk.Frame(notebook, style="Surface.TFrame", padding=10)
  344. config_tab = ttk.Frame(notebook, style="Surface.TFrame", padding=10)
  345. register_tab = ttk.Frame(notebook, style="Surface.TFrame", padding=10)
  346. log_tab = ttk.Frame(notebook, style="Surface.TFrame", padding=10)
  347. notebook.add(control_tab, text="控制与状态")
  348. notebook.add(config_tab, text="完整参数与多段")
  349. notebook.add(register_tab, text="寄存器诊断")
  350. notebook.add(log_tab, text="通信日志")
  351. self._build_control_tab(control_tab)
  352. self._build_config_tab(config_tab)
  353. self._build_register_tab(register_tab)
  354. self._build_log_tab(log_tab)
  355. notebook.select(control_tab)
  356. ttk.Label(
  357. outer,
  358. text="安全提示:首次发脉冲前请断开机械负载,仅连接测试灯或示波器;先低频验证 Q 点和方向电平。",
  359. foreground="#a13b22",
  360. background="#f2f5f8",
  361. ).grid(row=4, column=0, sticky="w", pady=(9, 0))
  362. def _build_control_tab(self, parent: ttk.Frame) -> None:
  363. parent.columnconfigure(0, weight=1, uniform="panel")
  364. parent.columnconfigure(1, weight=1, uniform="panel")
  365. parent.rowconfigure(0, weight=1)
  366. left = ttk.Frame(parent, style="Surface.TFrame")
  367. right = ttk.Frame(parent, style="Surface.TFrame")
  368. left.grid(row=0, column=0, sticky="nsew", padx=(0, 7))
  369. right.grid(row=0, column=1, sticky="nsew", padx=(7, 0))
  370. left.columnconfigure(0, weight=1)
  371. right.columnconfigure(0, weight=1)
  372. output_box = ttk.LabelFrame(left, text="模块输出映射(保存到设备 Flash)")
  373. output_box.grid(row=0, column=0, sticky="ew", pady=(0, 10))
  374. output_box.columnconfigure(1, weight=1)
  375. output_box.columnconfigure(3, weight=1)
  376. ttk.Label(output_box, text="脉冲点").grid(row=0, column=0, sticky="w", padx=(0, 6))
  377. 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)
  378. self.pulse_output_combo.grid(row=0, column=1, sticky="ew", padx=(0, 16))
  379. ttk.Label(output_box, text="方向点").grid(row=0, column=2, sticky="w", padx=(0, 6))
  380. 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)
  381. self.direction_output_combo.grid(row=0, column=3, sticky="ew", padx=(0, 12))
  382. self.save_outputs_button = ttk.Button(output_box, text="写入并保存", command=self._save_outputs, style="Primary.TButton")
  383. self.save_outputs_button.grid(row=0, column=4)
  384. ttk.Label(output_box, text="保存成功后断电不会丢失,下次上电自动恢复。", foreground="#61758a", background="#ffffff").grid(row=1, column=0, columnspan=5, sticky="w", pady=(9, 0))
  385. motion_box = ttk.LabelFrame(left, text="运动参数")
  386. motion_box.grid(row=1, column=0, sticky="ew", pady=(0, 10))
  387. motion_box.columnconfigure(1, weight=1)
  388. ttk.Label(motion_box, text="目标脉冲数").grid(row=0, column=0, sticky="w", padx=(0, 12), pady=5)
  389. self.pulses_spin = ttk.Spinbox(motion_box, textvariable=self.pulses_var, from_=-2147483648, to=2147483647)
  390. self.pulses_spin.grid(row=0, column=1, sticky="ew", pady=5)
  391. ttk.Label(motion_box, text="频率").grid(row=1, column=0, sticky="w", padx=(0, 12), pady=5)
  392. freq_line = ttk.Frame(motion_box, style="Surface.TFrame")
  393. freq_line.grid(row=1, column=1, sticky="ew", pady=5)
  394. freq_line.columnconfigure(0, weight=1)
  395. self.frequency_spin = ttk.Spinbox(freq_line, textvariable=self.frequency_var, from_=1, to=100000)
  396. self.frequency_spin.grid(row=0, column=0, sticky="ew")
  397. ttk.Label(freq_line, text="Hz(1–100000)", background="#ffffff").grid(row=0, column=1, padx=(8, 0))
  398. self.apply_frequency_button = ttk.Button(
  399. freq_line, text="应用频率", command=self._apply_running_frequency
  400. )
  401. self.apply_frequency_button.grid(row=0, column=2, padx=(10, 0))
  402. ttk.Label(motion_box, text="运行方向").grid(row=2, column=0, sticky="w", padx=(0, 12), pady=5)
  403. direction_line = ttk.Frame(motion_box, style="Surface.TFrame")
  404. direction_line.grid(row=2, column=1, sticky="w", pady=5)
  405. self.forward_radio = ttk.Radiobutton(direction_line, text="正向(0)", variable=self.direction_var, value=0)
  406. self.reverse_radio = ttk.Radiobutton(direction_line, text="反向(1)", variable=self.direction_var, value=1)
  407. self.forward_radio.grid(row=0, column=0, padx=(0, 18))
  408. self.reverse_radio.grid(row=0, column=1)
  409. actions = ttk.LabelFrame(left, text="运行控制")
  410. actions.grid(row=2, column=0, sticky="ew")
  411. for column in range(3):
  412. actions.columnconfigure(column, weight=1)
  413. self.start_button = ttk.Button(actions, text="启动/继续脉冲", command=self._start_motion, style="Primary.TButton")
  414. self.stop_button = ttk.Button(actions, text="受控停止", command=lambda: self._send_command(COMMAND_STOP, "stop"))
  415. self.emergency_button = ttk.Button(actions, text="重新运行脉冲", command=self._restart_motion, style="Danger.TButton")
  416. self.start_button.grid(row=0, column=0, sticky="ew", padx=(0, 6), pady=(0, 7))
  417. self.stop_button.grid(row=0, column=1, sticky="ew", padx=6, pady=(0, 7))
  418. self.emergency_button.grid(row=0, column=2, sticky="ew", padx=(6, 0), pady=(0, 7))
  419. self.lamp_test_button = ttk.Button(actions, text="低速正反转灯测", command=self._start_lamp_test)
  420. self.reset_button = ttk.Button(actions, text="复位状态", command=lambda: self._send_command(COMMAND_RESET, "reset"))
  421. self.clear_button = ttk.Button(actions, text="清零累计脉冲", command=lambda: self._send_command(COMMAND_CLEAR_COUNT, "clear"))
  422. self.lamp_test_button.grid(row=1, column=0, sticky="ew", padx=(0, 6))
  423. self.reset_button.grid(row=1, column=1, sticky="ew", padx=6)
  424. self.clear_button.grid(row=1, column=2, sticky="ew", padx=(6, 0))
  425. status_box = ttk.LabelFrame(right, text="实时状态")
  426. status_box.grid(row=0, column=0, sticky="nsew")
  427. status_box.columnconfigure(1, weight=1)
  428. rows = (
  429. ("运行状态", self.state_var),
  430. ("状态标志", self.flags_var),
  431. ("当前频率", self.current_frequency_var),
  432. ("累计脉冲", self.total_pulses_var),
  433. ("停止原因", self.stop_reason_var),
  434. ("最后错误", self.error_var),
  435. ("设备当前 Q 点", self.saved_outputs_var),
  436. )
  437. for row, (label, variable) in enumerate(rows):
  438. ttk.Label(status_box, text=label).grid(row=row, column=0, sticky="w", padx=(0, 24), pady=5)
  439. ttk.Label(status_box, textvariable=variable, style="Value.TLabel").grid(row=row, column=1, sticky="e", pady=5)
  440. ttk.Separator(status_box).grid(row=len(rows), column=0, columnspan=2, sticky="ew", pady=(5, 8))
  441. ttk.Label(status_box, text="本次运动进度").grid(row=len(rows) + 1, column=0, columnspan=2, sticky="w")
  442. self.progress = ttk.Progressbar(status_box, maximum=100, value=0)
  443. self.progress.grid(row=len(rows) + 2, column=0, columnspan=2, sticky="ew", pady=(8, 4))
  444. ttk.Label(status_box, textvariable=self.progress_text_var, background="#ffffff", foreground="#61758a").grid(row=len(rows) + 3, column=0, columnspan=2, sticky="e")
  445. self.read_now_button = ttk.Button(status_box, text="立即读取状态", command=lambda: self._submit("snapshot", purpose="manual"))
  446. self.read_now_button.grid(row=len(rows) + 4, column=0, columnspan=2, sticky="ew", pady=(10, 0))
  447. def _build_register_tab(self, parent: ttk.Frame) -> None:
  448. parent.rowconfigure(1, weight=1)
  449. parent.columnconfigure(0, weight=1)
  450. toolbar = ttk.Frame(parent, style="Surface.TFrame")
  451. toolbar.grid(row=0, column=0, sticky="ew", pady=(0, 8))
  452. toolbar.columnconfigure(0, weight=1)
  453. ttk.Label(toolbar, text="需求版读取 1000/1100/2000/3000;legacy 读取 0/100。", background="#ffffff", foreground="#61758a").grid(row=0, column=0, sticky="w")
  454. self.raw_button = ttk.Button(toolbar, text="刷新寄存器", command=lambda: self._submit("raw", purpose="raw"), style="Primary.TButton")
  455. self.raw_button.grid(row=0, column=1, sticky="e")
  456. tree_frame = ttk.Frame(parent, style="Surface.TFrame")
  457. tree_frame.grid(row=1, column=0, sticky="nsew")
  458. tree_frame.rowconfigure(0, weight=1)
  459. tree_frame.columnconfigure(0, weight=1)
  460. self.register_tree = ttk.Treeview(tree_frame, columns=("address", "hex", "value", "meaning"), show="headings", selectmode="browse")
  461. self.register_tree.heading("address", text="地址")
  462. self.register_tree.heading("hex", text="十六进制")
  463. self.register_tree.heading("value", text="无符号值")
  464. self.register_tree.heading("meaning", text="项目定义")
  465. self.register_tree.column("address", width=90, anchor="center", stretch=False)
  466. self.register_tree.column("hex", width=130, anchor="center", stretch=False)
  467. self.register_tree.column("value", width=140, anchor="e", stretch=False)
  468. self.register_tree.column("meaning", width=340, anchor="w")
  469. scroll = ttk.Scrollbar(tree_frame, orient="vertical", command=self.register_tree.yview)
  470. self.register_tree.configure(yscrollcommand=scroll.set)
  471. self.register_tree.grid(row=0, column=0, sticky="nsew")
  472. scroll.grid(row=0, column=1, sticky="ns")
  473. def _build_config_tab(self, parent: ttk.Frame) -> None:
  474. parent.columnconfigure(0, weight=1)
  475. parent.rowconfigure(1, weight=1)
  476. global_box = ttk.LabelFrame(parent, text="系统与运动参数(0x1000..0x1013)")
  477. global_box.grid(row=0, column=0, sticky="ew", pady=(0, 10))
  478. for column in (1, 3, 5, 7):
  479. global_box.columnconfigure(column, weight=1)
  480. fields = (
  481. ("WAIT信号", self.wait_input_var, ("X4", "X5")),
  482. ("EXT信号", self.ext_input_var, ("X4", "X5")),
  483. ("发送模式", self.send_mode_var, ("完成模式", "后续模式")),
  484. ("方向逻辑", self.direction_logic_var, ("正逻辑", "负逻辑")),
  485. ("加减速模式", self.accel_mode_var, ("直线", "S曲线", "正弦曲线")),
  486. ("运行模式", self.run_mode_var, ("相对", "绝对")),
  487. )
  488. for index, (label, variable, values) in enumerate(fields):
  489. row, pair = divmod(index, 3)
  490. column = pair * 2
  491. ttk.Label(global_box, text=label).grid(row=row, column=column, sticky="w", padx=(0, 6), pady=4)
  492. 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)
  493. numeric_fields = (
  494. ("方向延时 ms", self.direction_delay_var, 0, 65535),
  495. ("总段数", self.segment_count_var, 1, 10),
  496. ("起始段", self.start_segment_var, 1, 10),
  497. ("默认速度 Hz", self.default_speed_var, 1, 100000),
  498. ("起始速度 Hz", self.start_speed_var, 0, 100000),
  499. ("终止速度 Hz", self.end_speed_var, 0, 100000),
  500. ("加速时间 ms", self.accel_time_var, 0, 65535),
  501. ("减速时间 ms", self.decel_time_var, 0, 65535),
  502. )
  503. for index, (label, variable, minimum, maximum) in enumerate(numeric_fields):
  504. row, pair = divmod(index, 4)
  505. row += 2
  506. column = pair * 2
  507. ttk.Label(global_box, text=label).grid(row=row, column=column, sticky="w", padx=(0, 6), pady=4)
  508. 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)
  509. body = ttk.Frame(parent, style="Surface.TFrame")
  510. body.grid(row=1, column=0, sticky="nsew")
  511. body.columnconfigure(0, weight=3)
  512. body.columnconfigure(1, weight=2)
  513. body.rowconfigure(0, weight=1)
  514. table_box = ttk.LabelFrame(body, text="1~10 段参数(0x1100..0x1197)")
  515. table_box.grid(row=0, column=0, sticky="nsew", padx=(0, 7))
  516. table_box.rowconfigure(0, weight=1)
  517. table_box.columnconfigure(0, weight=1)
  518. self.segment_tree = ttk.Treeview(table_box, columns=("segment", "frequency", "pulses", "wait", "wait_ms", "act_ms", "jump"), show="headings", height=10)
  519. 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)):
  520. self.segment_tree.heading(key, text=title)
  521. self.segment_tree.column(key, width=width, anchor="center", stretch=key == "wait")
  522. self.segment_tree.grid(row=0, column=0, sticky="nsew")
  523. self.segment_tree.bind("<<TreeviewSelect>>", self._select_segment)
  524. editor = ttk.LabelFrame(body, text="当前段编辑")
  525. editor.grid(row=0, column=1, sticky="nsew", padx=(7, 0))
  526. editor.columnconfigure(1, weight=1)
  527. segment_fields = (
  528. ("段号", self.segment_index_var, 1, 10),
  529. ("频率 Hz", self.segment_frequency_var, 0, 100000),
  530. ("脉冲数", self.segment_pulses_var, -2147483648, 2147483647),
  531. ("WAIT时间 ms", self.segment_wait_time_var, 0, 65535),
  532. ("ACT时间 ms", self.segment_act_time_var, 0, 65535),
  533. ("跳转段(0默认)", self.segment_jump_var, 0, 10),
  534. )
  535. for row, (label, variable, minimum, maximum) in enumerate(segment_fields):
  536. ttk.Label(editor, text=label).grid(row=row, column=0, sticky="w", padx=(0, 8), pady=5)
  537. widget = ttk.Spinbox(editor, textvariable=variable, from_=minimum, to=maximum)
  538. if row == 0:
  539. widget.configure(state="readonly")
  540. widget.grid(row=row, column=1, sticky="ew", pady=5)
  541. ttk.Label(editor, text="等待条件").grid(row=6, column=0, sticky="w", padx=(0, 8), pady=5)
  542. ttk.Combobox(editor, textvariable=self.segment_wait_var,
  543. values=("WAIT时间", "WAIT信号", "ACT时间", "EXT信号", "EXT或完成"),
  544. state="readonly").grid(row=6, column=1, sticky="ew", pady=5)
  545. self.apply_segment_button = ttk.Button(editor, text="应用当前段", command=self._apply_segment_editor)
  546. self.apply_segment_button.grid(row=7, column=0, columnspan=2, sticky="ew", pady=(10, 5))
  547. button_line = ttk.Frame(editor, style="Surface.TFrame")
  548. button_line.grid(row=8, column=0, columnspan=2, sticky="ew")
  549. button_line.columnconfigure((0, 1), weight=1)
  550. self.read_config_button = ttk.Button(button_line, text="从设备读取全部", command=self._read_full_config)
  551. self.write_config_button = ttk.Button(button_line, text="校验并写入全部", command=self._write_full_config, style="Primary.TButton")
  552. self.read_config_button.grid(row=0, column=0, sticky="ew", padx=(0, 4))
  553. self.write_config_button.grid(row=0, column=1, sticky="ew", padx=(4, 0))
  554. config_actions = ttk.Frame(editor, style="Surface.TFrame")
  555. config_actions.grid(row=9, column=0, columnspan=2, sticky="ew", pady=(8, 0))
  556. config_actions.columnconfigure((0, 1, 2), weight=1)
  557. self.config_start_button = ttk.Button(
  558. config_actions, text="写入并启动/继续",
  559. command=lambda: self._start_config_sequence(False), style="Primary.TButton")
  560. self.config_restart_button = ttk.Button(
  561. config_actions, text="写入并重新启动",
  562. command=lambda: self._start_config_sequence(True), style="Danger.TButton")
  563. self.config_stop_button = ttk.Button(
  564. config_actions, text="停止", command=lambda: self._send_command(COMMAND_STOP, "stop"))
  565. self.config_start_button.grid(row=0, column=0, sticky="ew", padx=(0, 4))
  566. self.config_restart_button.grid(row=0, column=1, sticky="ew", padx=4)
  567. self.config_stop_button.grid(row=0, column=2, sticky="ew", padx=(4, 0))
  568. config_actions2 = ttk.Frame(editor, style="Surface.TFrame")
  569. config_actions2.grid(row=10, column=0, columnspan=2, sticky="ew", pady=(5, 0))
  570. config_actions2.columnconfigure((0, 1, 2), weight=1)
  571. self.config_emergency_button = ttk.Button(
  572. config_actions2, text="立即停止",
  573. command=lambda: self._send_command(COMMAND_IMMEDIATE_STOP, "immediate_stop"))
  574. self.config_reset_button = ttk.Button(
  575. config_actions2, text="复位", command=lambda: self._send_command(COMMAND_RESET, "reset"))
  576. self.config_clear_button = ttk.Button(
  577. config_actions2, text="清零累计脉冲",
  578. command=lambda: self._send_command(COMMAND_CLEAR_COUNT, "clear"))
  579. self.config_emergency_button.grid(row=0, column=0, sticky="ew", padx=(0, 4))
  580. self.config_reset_button.grid(row=0, column=1, sticky="ew", padx=4)
  581. self.config_clear_button.grid(row=0, column=2, sticky="ew", padx=(4, 0))
  582. ttk.Label(editor, text="X4/X5、实际频率和曲线需配合示波器/人工触发验收。", wraplength=300, foreground="#61758a", background="#ffffff").grid(row=11, column=0, columnspan=2, sticky="w", pady=(10, 0))
  583. self._refresh_segment_tree()
  584. def _build_log_tab(self, parent: ttk.Frame) -> None:
  585. parent.rowconfigure(1, weight=1)
  586. parent.columnconfigure(0, weight=1)
  587. toolbar = ttk.Frame(parent, style="Surface.TFrame")
  588. toolbar.grid(row=0, column=0, sticky="ew", pady=(0, 8))
  589. toolbar.columnconfigure(0, weight=1)
  590. ttk.Checkbutton(toolbar, text="记录原始 TX / RX 帧", variable=self.capture_frames_var).grid(row=0, column=0, sticky="w")
  591. ttk.Label(toolbar, text=f"最多保留 {MAX_LOG_LINES} 行", background="#ffffff", foreground="#61758a").grid(row=0, column=1, padx=12)
  592. ttk.Button(toolbar, text="导出", command=self._export_log).grid(row=0, column=2, padx=(0, 6))
  593. ttk.Button(toolbar, text="清空", command=self._clear_log).grid(row=0, column=3)
  594. text_frame = ttk.Frame(parent, style="Surface.TFrame")
  595. text_frame.grid(row=1, column=0, sticky="nsew")
  596. text_frame.rowconfigure(0, weight=1)
  597. text_frame.columnconfigure(0, weight=1)
  598. 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")
  599. y_scroll = ttk.Scrollbar(text_frame, orient="vertical", command=self.log_text.yview)
  600. x_scroll = ttk.Scrollbar(text_frame, orient="horizontal", command=self.log_text.xview)
  601. self.log_text.configure(yscrollcommand=y_scroll.set, xscrollcommand=x_scroll.set)
  602. self.log_text.grid(row=0, column=0, sticky="nsew")
  603. y_scroll.grid(row=0, column=1, sticky="ns")
  604. x_scroll.grid(row=1, column=0, sticky="ew")
  605. def _refresh_ports(self) -> None:
  606. if list_ports is None:
  607. messagebox.showerror("缺少依赖", "未安装 pyserial,请先运行 start_plsr_test.bat。")
  608. return
  609. current = self.port_var.get()
  610. ports = sorted((item.device for item in list_ports.comports()), key=str.upper)
  611. self.port_combo["values"] = ports
  612. if current in ports:
  613. self.port_var.set(current)
  614. elif "COM9" in ports:
  615. self.port_var.set("COM9")
  616. elif ports:
  617. self.port_var.set(ports[0])
  618. else:
  619. self.port_var.set("")
  620. self.operation_var.set(f"检测到 {len(ports)} 个串口" if ports else "未检测到串口,请检查 USB 转串口连接")
  621. def _toggle_connection(self) -> None:
  622. if self.job_active:
  623. return
  624. if self.connected:
  625. self.auto_phase = None
  626. self._submit("disconnect", purpose="disconnect")
  627. return
  628. try:
  629. port = self.port_var.get().strip()
  630. if not port:
  631. raise ValueError("请先选择串口")
  632. baud = int(self.baud_var.get())
  633. slave = int(self.slave_var.get())
  634. if not 1 <= slave <= 247:
  635. raise ValueError("站号必须为 1–247")
  636. except ValueError as exc:
  637. messagebox.showerror("连接参数错误", str(exc))
  638. return
  639. self.connection_var.set("连接中…")
  640. self.operation_var.set(f"正在连接 {port},{baud} / 8-E-1 / 站号 {slave}")
  641. self._submit("connect", purpose="connect", port=port, baud=baud, slave=slave, protocol=self.protocol_var.get())
  642. def _submit(self, name: str, purpose: str = "", **data: Any) -> bool:
  643. job = {"name": name, "purpose": purpose, **data}
  644. if self.job_active:
  645. if self.poll_active and purpose != "poll" and self.deferred_job is None:
  646. self.deferred_job = job
  647. self._update_controls()
  648. return True
  649. return False
  650. if name not in ("connect", "disconnect") and not self.connected:
  651. messagebox.showwarning("设备未连接", "请先连接设备。")
  652. return False
  653. self._start_job(job)
  654. return True
  655. def _start_job(self, job: dict[str, Any]) -> None:
  656. self.job_active = True
  657. self.poll_active = job["name"] == "snapshot" and job.get("purpose") == "poll"
  658. self.jobs.put(job)
  659. if not self.poll_active:
  660. self._update_controls()
  661. def _start_deferred_job(self) -> None:
  662. if self.deferred_job is None or self.job_active:
  663. return
  664. job = self.deferred_job
  665. self.deferred_job = None
  666. self._start_job(job)
  667. def _parse_outputs(self) -> tuple[int, int]:
  668. if self.protocol_var.get() == MAP_SPEC:
  669. pulse = int(self.pulse_output_var.get().removeprefix("Y"))
  670. direction = int(self.direction_output_var.get().removeprefix("Y")) - 12
  671. if not 0 <= pulse <= 3 or not 0 <= direction <= 3:
  672. raise ValueError("需求版输出必须为 Y0..Y3 与 Y12..Y15")
  673. else:
  674. pulse = int(self.pulse_output_var.get().removeprefix("Q"))
  675. direction = int(self.direction_output_var.get().removeprefix("Q"))
  676. if pulse == direction:
  677. raise ValueError("脉冲点与方向点不能相同")
  678. return pulse, direction
  679. def _parse_motion(self) -> tuple[int, int, int]:
  680. pulses = int(self.pulses_var.get())
  681. frequency = int(self.frequency_var.get())
  682. direction = int(self.direction_var.get())
  683. if self.protocol_var.get() == MAP_SPEC:
  684. if pulses == 0 or not -2147483648 <= pulses <= 2147483647:
  685. raise ValueError("需求版脉冲个数必须为 -2147483648..2147483647 且不能为 0")
  686. validate_frequency(frequency)
  687. else:
  688. if not 1 <= pulses <= 0xFFFFFFFF:
  689. raise ValueError("legacy 目标脉冲数必须为 1–4294967295")
  690. if not 1 <= frequency <= 1000:
  691. raise ValueError("legacy 频率必须为 1–1000 Hz")
  692. return pulses, frequency, direction
  693. def _segment_from_editor(self) -> SegmentParameters:
  694. wait_values = {"WAIT时间": 0, "WAIT信号": 1, "ACT时间": 2, "EXT信号": 3, "EXT或完成": 4}
  695. segment = SegmentParameters(
  696. frequency_hz=int(self.segment_frequency_var.get()),
  697. pulses=int(self.segment_pulses_var.get()),
  698. wait_condition=wait_values[self.segment_wait_var.get()],
  699. wait_time_ms=int(self.segment_wait_time_var.get()),
  700. act_time_ms=int(self.segment_act_time_var.get()),
  701. jump_segment=int(self.segment_jump_var.get()),
  702. )
  703. segment.validate(int(self.segment_index_var.get()))
  704. return segment
  705. def _apply_segment_editor(self) -> None:
  706. try:
  707. index = int(self.segment_index_var.get())
  708. self.segment_models[index - 1] = self._segment_from_editor()
  709. except (KeyError, ValueError) as exc:
  710. messagebox.showerror("段参数错误", str(exc))
  711. return
  712. self._refresh_segment_tree(select=index)
  713. self.operation_var.set(f"第 {index} 段参数已应用到待写配置")
  714. def _select_segment(self, _event: object = None) -> None:
  715. selection = self.segment_tree.selection()
  716. if not selection:
  717. return
  718. index = int(self.segment_tree.item(selection[0], "values")[0])
  719. self._load_segment_editor(index)
  720. def _load_segment_editor(self, index: int) -> None:
  721. wait_names = ("WAIT时间", "WAIT信号", "ACT时间", "EXT信号", "EXT或完成")
  722. segment = self.segment_models[index - 1]
  723. self.segment_index_var.set(str(index))
  724. self.segment_frequency_var.set(str(segment.frequency_hz))
  725. self.segment_pulses_var.set(str(segment.pulses))
  726. self.segment_wait_var.set(wait_names[segment.wait_condition] if 0 <= segment.wait_condition < 5 else "WAIT时间")
  727. self.segment_wait_time_var.set(str(segment.wait_time_ms))
  728. self.segment_act_time_var.set(str(segment.act_time_ms))
  729. self.segment_jump_var.set(str(segment.jump_segment))
  730. def _refresh_segment_tree(self, select: int = 1) -> None:
  731. wait_names = ("WAIT时间", "WAIT信号", "ACT时间", "EXT信号", "EXT或完成")
  732. self.segment_tree.delete(*self.segment_tree.get_children())
  733. selected = None
  734. for index, segment in enumerate(self.segment_models, 1):
  735. item = self.segment_tree.insert("", "end", values=(index, segment.frequency_hz, segment.pulses,
  736. wait_names[segment.wait_condition] if 0 <= segment.wait_condition < 5 else segment.wait_condition,
  737. segment.wait_time_ms, segment.act_time_ms, segment.jump_segment))
  738. if index == select:
  739. selected = item
  740. if selected:
  741. self.segment_tree.selection_set(selected)
  742. self.segment_tree.focus(selected)
  743. def _configuration_from_ui(self) -> PlsrConfiguration:
  744. self.segment_models[int(self.segment_index_var.get()) - 1] = self._segment_from_editor()
  745. cfg = PlsrConfiguration(
  746. pulse_output=self._parse_outputs()[0],
  747. direction_output=self._parse_outputs()[1],
  748. wait_input=0 if self.wait_input_var.get() == "X4" else 1,
  749. ext_input=0 if self.ext_input_var.get() == "X4" else 1,
  750. send_mode=0 if self.send_mode_var.get() == "完成模式" else 1,
  751. direction_delay_ms=int(self.direction_delay_var.get()),
  752. direction_logic=0 if self.direction_logic_var.get() == "正逻辑" else 1,
  753. accel_mode={"直线": 0, "S曲线": 1, "正弦曲线": 2}[self.accel_mode_var.get()],
  754. run_mode=0 if self.run_mode_var.get() == "相对" else 1,
  755. segment_count=int(self.segment_count_var.get()),
  756. start_segment=int(self.start_segment_var.get()),
  757. default_speed_hz=int(self.default_speed_var.get()),
  758. start_speed_hz=int(self.start_speed_var.get()),
  759. end_speed_hz=int(self.end_speed_var.get()),
  760. accel_time_ms=int(self.accel_time_var.get()),
  761. decel_time_ms=int(self.decel_time_var.get()),
  762. segments=list(self.segment_models),
  763. )
  764. cfg.validate()
  765. return cfg
  766. def _apply_configuration(self, cfg: PlsrConfiguration) -> None:
  767. self.pulse_output_var.set(f"Y{cfg.pulse_output}")
  768. self.direction_output_var.set(f"Y{cfg.direction_output + 12}")
  769. self.wait_input_var.set(f"X{cfg.wait_input + 4}")
  770. self.ext_input_var.set(f"X{cfg.ext_input + 4}")
  771. self.send_mode_var.set(("完成模式", "后续模式")[cfg.send_mode])
  772. self.direction_delay_var.set(str(cfg.direction_delay_ms))
  773. self.direction_logic_var.set(("正逻辑", "负逻辑")[cfg.direction_logic])
  774. self.accel_mode_var.set(("直线", "S曲线", "正弦曲线")[cfg.accel_mode])
  775. self.run_mode_var.set(("相对", "绝对")[cfg.run_mode])
  776. self.segment_count_var.set(str(cfg.segment_count))
  777. self.start_segment_var.set(str(cfg.start_segment))
  778. self.default_speed_var.set(str(cfg.default_speed_hz))
  779. self.start_speed_var.set(str(cfg.start_speed_hz))
  780. self.end_speed_var.set(str(cfg.end_speed_hz))
  781. self.accel_time_var.set(str(cfg.accel_time_ms))
  782. self.decel_time_var.set(str(cfg.decel_time_ms))
  783. self.segment_models = list(cfg.segments)
  784. self._refresh_segment_tree(select=cfg.start_segment)
  785. self._load_segment_editor(cfg.start_segment)
  786. def _read_full_config(self) -> None:
  787. if self.protocol_var.get() != MAP_SPEC:
  788. messagebox.showwarning("映射不支持", "完整参数页仅适用于 spec 参数表映射。")
  789. return
  790. self.operation_var.set("正在读取系统参数和 10 段路径")
  791. self._submit("read_config", purpose="read_config")
  792. def _write_full_config(self) -> None:
  793. if self.protocol_var.get() != MAP_SPEC:
  794. messagebox.showwarning("映射不支持", "完整参数页仅适用于 spec 参数表映射。")
  795. return
  796. try:
  797. config = self._configuration_from_ui()
  798. except (KeyError, ValueError) as exc:
  799. messagebox.showerror("完整参数校验失败", str(exc))
  800. return
  801. self._refresh_segment_tree(select=config.start_segment)
  802. self.operation_var.set(f"正在写入完整配置:{config.segment_count} 段,从第 {config.start_segment} 段执行")
  803. self._submit("write_config", purpose="write_config", config=config)
  804. def _confirm_motion(self, title: str, detail: str) -> bool:
  805. pulse = self.pulse_output_var.get()
  806. direction = self.direction_output_var.get()
  807. return messagebox.askyesno(
  808. title,
  809. "即将产生真实硬件输出。\n\n"
  810. f"脉冲点:{pulse}\n方向点:{direction}\n{detail}\n\n"
  811. "请确认机械负载已断开,当前只连接测试灯或示波器,且现场允许输出。",
  812. icon="warning",
  813. )
  814. def _save_outputs(self) -> None:
  815. try:
  816. pulse, direction = self._parse_outputs()
  817. except ValueError as exc:
  818. messagebox.showerror("Q 点设置错误", str(exc))
  819. return
  820. output_text = (f"脉冲 Y{pulse},方向 Y{direction + 12}" if self.protocol_var.get() == MAP_SPEC
  821. else f"脉冲 Q{pulse},方向 Q{direction}")
  822. self.operation_var.set(f"正在写入输出映射:{output_text}")
  823. self._submit("configure", purpose="save_outputs", pulse_output=pulse, direction_output=direction)
  824. def _start_motion(self) -> None:
  825. self._start_or_restart_motion(False)
  826. def _restart_motion(self) -> None:
  827. self._start_or_restart_motion(True)
  828. def _apply_running_frequency(self) -> None:
  829. try:
  830. frequency = int(self.frequency_var.get())
  831. validate_frequency(frequency)
  832. except ValueError as exc:
  833. messagebox.showerror("频率参数错误", str(exc))
  834. return
  835. self.operation_var.set(f"正在将运行频率修改为 {frequency} Hz")
  836. self._submit(
  837. "frequency", purpose="update_frequency", frequency=frequency
  838. )
  839. def _start_or_restart_motion(self, restart: bool) -> None:
  840. try:
  841. pulses, frequency, direction = self._parse_motion()
  842. pulse, direction_output = self._parse_outputs()
  843. if self.last_snapshot is None or (
  844. pulse != self.last_snapshot.pulse_output
  845. or direction_output != self.last_snapshot.direction_output
  846. ):
  847. raise ValueError("所选 Q 点尚未写入设备,请先点击“写入并保存”")
  848. except ValueError as exc:
  849. messagebox.showerror("运动参数错误", str(exc))
  850. return
  851. direction_text = "反向" if direction else "正向"
  852. action = "重新运行" if restart else "启动/继续"
  853. if not self._confirm_motion(f"确认{action}脉冲", f"{direction_text},{pulses} 脉冲,{frequency} Hz"):
  854. return
  855. self.auto_phase = None
  856. plan = (abs(pulses), frequency, direction)
  857. continuing = (not restart and self.last_snapshot is not None
  858. and self.last_snapshot.state == 6 and self.motion_plan == plan)
  859. if not continuing:
  860. self.run_baseline = self.last_snapshot.total_pulses if self.last_snapshot else 0
  861. self.run_target = abs(pulses)
  862. self.motion_plan = plan
  863. self._reset_progress()
  864. self.operation_var.set(f"正在{action}:{direction_text} / {pulses} 脉冲 / {frequency} Hz")
  865. self._submit("start", purpose="normal_restart" if restart else "normal_start",
  866. pulses=pulses, frequency=frequency, direction=direction,
  867. restart=restart)
  868. def _start_config_sequence(self, restart: bool) -> None:
  869. if self.protocol_var.get() != MAP_SPEC:
  870. messagebox.showwarning("映射不支持", "完整参数与多段启动仅适用于 spec 参数表映射。")
  871. return
  872. try:
  873. config = self._configuration_from_ui()
  874. except (KeyError, ValueError) as exc:
  875. messagebox.showerror("完整参数校验失败", str(exc))
  876. return
  877. action = "重新启动" if restart else "启动/继续"
  878. detail = (
  879. f"{action}完整多段任务\n"
  880. f"起始段:{config.start_segment} / 共 {config.segment_count} 段\n"
  881. f"发送模式:{'完成模式' if config.send_mode == 0 else '后续模式'}\n"
  882. "设备将先写入当前页全部参数,再执行启动命令。"
  883. )
  884. if not self._confirm_motion(f"确认{action}多段脉冲", detail):
  885. return
  886. self._refresh_segment_tree(select=config.start_segment)
  887. self.auto_phase = None
  888. purpose = "config_restart" if restart else "config_start"
  889. self.operation_var.set(f"正在写入并{action}完整多段任务")
  890. self._submit(
  891. "write_config_command", purpose=purpose, config=config,
  892. command=COMMAND_RESTART if restart else COMMAND_START,
  893. )
  894. def _start_lamp_test(self) -> None:
  895. try:
  896. pulse, direction = self._parse_outputs()
  897. except ValueError as exc:
  898. messagebox.showerror("Q 点设置错误", str(exc))
  899. return
  900. if not self._confirm_motion("确认低速正反转灯测", "正向 6 脉冲 / 2 Hz,然后反向 6 脉冲 / 2 Hz"):
  901. return
  902. self.auto_phase = "config"
  903. self.lamp_test_baseline = self.last_snapshot.total_pulses if self.last_snapshot else 0
  904. self.run_baseline = self.lamp_test_baseline
  905. self.run_target = 12
  906. self._reset_progress()
  907. self.operation_var.set("灯测 1/4:保存 Q 点配置")
  908. self._submit("configure", purpose="auto_config", pulse_output=pulse, direction_output=direction)
  909. def _send_command(self, command: int, purpose: str) -> None:
  910. if purpose in ("stop", "immediate_stop"):
  911. self.auto_phase = None
  912. labels = {
  913. "stop": "正在发送受控停止命令",
  914. "immediate_stop": "正在发送立即停止命令",
  915. "reset": "正在复位状态",
  916. "clear": "正在清零累计脉冲",
  917. }
  918. self.operation_var.set(labels[purpose])
  919. self._submit("command", purpose=purpose, command=command)
  920. def _drain_events(self) -> None:
  921. try:
  922. while True:
  923. event = self.events.get_nowait()
  924. if event["type"] == "log":
  925. if self.capture_frames_var.get():
  926. self._append_log(event["line"])
  927. elif event["type"] == "result":
  928. self._handle_result(event)
  929. elif event["type"] == "error":
  930. self._handle_error(event)
  931. except queue.Empty:
  932. pass
  933. if not self.closing:
  934. self.after(50, self._drain_events)
  935. def _handle_result(self, event: dict[str, Any]) -> None:
  936. self.job_active = False
  937. self.poll_active = False
  938. self.communication_errors = 0
  939. name = event["name"]
  940. purpose = event["purpose"]
  941. result = event["result"]
  942. if name == "connect":
  943. self.connected = True
  944. self.connection_var.set("已连接")
  945. self.connection_label.configure(style="StatusOn.TLabel")
  946. self.connect_button.configure(text="断开连接")
  947. self.operation_var.set(f"连接成功:{self.port_var.get()} / {self.baud_var.get()} / 8-E-1 / 站号 {self.slave_var.get()}")
  948. self._append_log(f"{time.strftime('%H:%M:%S')} INFO 已连接 {self.port_var.get()}")
  949. self._apply_snapshot(result)
  950. self._sync_output_selectors(result)
  951. elif name == "disconnect":
  952. self._set_disconnected("已断开连接")
  953. elif name == "raw":
  954. self._show_raw_registers(result)
  955. self.operation_var.set("寄存器读取完成")
  956. elif isinstance(result, PlsrConfiguration):
  957. self._apply_configuration(result)
  958. self.operation_var.set("完整参数已从设备回读" if purpose == "read_config" else "完整参数写入并回读校验完成")
  959. elif isinstance(result, Snapshot):
  960. self._apply_snapshot(result)
  961. if name == "configure":
  962. self._sync_output_selectors(result)
  963. if purpose == "save_outputs":
  964. output_text = (f"脉冲 Y{result.pulse_output},方向 Y{result.direction_output + 12}"
  965. if result.protocol == MAP_SPEC else
  966. f"脉冲 Q{result.pulse_output},方向 Q{result.direction_output}")
  967. self.operation_var.set(f"输出映射已写入:{output_text}")
  968. elif purpose == "auto_config":
  969. self.auto_phase = "forward"
  970. self.run_baseline = result.total_pulses
  971. self.lamp_test_baseline = result.total_pulses
  972. self.operation_var.set("灯测 2/3:正向 6 脉冲 / 2 Hz")
  973. self._submit("start", purpose="auto_forward", pulses=6, frequency=2,
  974. direction=0, restart=True)
  975. elif purpose == "auto_forward":
  976. self.operation_var.set("灯测正向运行中:观察脉冲灯亮灭")
  977. elif purpose == "auto_reverse":
  978. self.operation_var.set("灯测反向运行中:脉冲灯仍应亮灭,方向点电平应改变")
  979. elif purpose == "normal_start":
  980. self.operation_var.set("运动已启动/继续,正在轮询状态")
  981. elif purpose == "normal_restart":
  982. self.operation_var.set("运动已从本次任务起点重新运行,正在轮询状态")
  983. elif purpose == "config_start":
  984. self.operation_var.set("完整多段参数已写入,任务已启动/继续")
  985. elif purpose == "config_restart":
  986. self.operation_var.set("完整多段参数已写入,任务已重新启动")
  987. elif purpose == "update_frequency":
  988. self.operation_var.set(
  989. f"运行频率已修改为 {result.current_frequency} Hz"
  990. )
  991. elif purpose == "clear":
  992. self.run_baseline = result.total_pulses
  993. self.run_target = 0
  994. self._reset_progress()
  995. self.operation_var.set("累计脉冲已清零")
  996. elif purpose == "reset":
  997. self.operation_var.set("状态已复位")
  998. elif purpose == "stop":
  999. self.operation_var.set("受控停止命令已执行")
  1000. elif purpose == "immediate_stop":
  1001. self.operation_var.set("立即停止命令已执行")
  1002. elif name == "snapshot":
  1003. if purpose == "manual":
  1004. self.operation_var.set("状态读取完成")
  1005. self._advance_lamp_test(result)
  1006. self._start_deferred_job()
  1007. self._update_controls()
  1008. def _handle_error(self, event: dict[str, Any]) -> None:
  1009. self.job_active = False
  1010. self.poll_active = False
  1011. name = event["name"]
  1012. error = event["error"]
  1013. purpose = event["purpose"]
  1014. self._append_log(f"{time.strftime('%H:%M:%S')} ERROR {error}")
  1015. self.operation_var.set(f"通信/操作失败:{error}")
  1016. if name == "connect":
  1017. self._set_disconnected("连接失败")
  1018. messagebox.showerror("连接失败", error)
  1019. else:
  1020. if purpose.startswith("auto_"):
  1021. self.auto_phase = None
  1022. messagebox.showerror("灯测中止", error)
  1023. if name == "snapshot":
  1024. self.communication_errors += 1
  1025. if self.communication_errors >= 3:
  1026. self._set_disconnected("通信中断")
  1027. messagebox.showerror("通信中断", "连续 3 次读取失败,已停止自动轮询。\n请检查串口占用、接线和设备供电后重新连接。")
  1028. self._start_deferred_job()
  1029. self._update_controls()
  1030. def _apply_snapshot(self, snapshot: Snapshot) -> None:
  1031. self.last_snapshot = snapshot
  1032. state = STATE_TEXT.get(snapshot.state, f"未知({snapshot.state})")
  1033. self.state_var.set(f"{state}({snapshot.state})")
  1034. flag_names = []
  1035. if snapshot.flags & 0x0001:
  1036. flag_names.append("运行")
  1037. if snapshot.flags & 0x0002:
  1038. flag_names.append("完成")
  1039. if snapshot.flags & 0x0004:
  1040. flag_names.append("错误")
  1041. suffix = " / ".join(flag_names) if flag_names else "无"
  1042. self.flags_var.set(f"0x{snapshot.flags:04X} · {suffix}")
  1043. self.current_frequency_var.set(f"{snapshot.current_frequency} Hz")
  1044. self.total_pulses_var.set(f"{snapshot.total_pulses:,}")
  1045. self.stop_reason_var.set(STOP_REASON_TEXT.get(snapshot.stop_reason, str(snapshot.stop_reason)))
  1046. self.error_var.set(ERROR_TEXT.get(snapshot.last_error, f"{snapshot.last_error} · 未知错误"))
  1047. self.saved_outputs_var.set((f"脉冲 Y{snapshot.pulse_output} / 方向 Y{snapshot.direction_output + 12}")
  1048. if snapshot.protocol == MAP_SPEC else
  1049. f"脉冲 Q{snapshot.pulse_output} / 方向 Q{snapshot.direction_output}")
  1050. self._update_progress(snapshot)
  1051. def _sync_output_selectors(self, snapshot: Snapshot) -> None:
  1052. if snapshot.protocol == MAP_SPEC:
  1053. self.pulse_output_var.set(f"Y{snapshot.pulse_output}")
  1054. self.direction_output_var.set(f"Y{snapshot.direction_output + 12}")
  1055. else:
  1056. self.pulse_output_var.set(f"Q{snapshot.pulse_output}")
  1057. self.direction_output_var.set(f"Q{snapshot.direction_output}")
  1058. def _advance_lamp_test(self, snapshot: Snapshot) -> None:
  1059. if self.auto_phase == "forward" and snapshot.state in (5, 6, 7):
  1060. actual = snapshot.total_pulses - self.run_baseline
  1061. if snapshot.state != 5 or actual != 6:
  1062. self.auto_phase = None
  1063. messagebox.showerror("正向灯测失败", f"状态:{STATE_TEXT.get(snapshot.state, snapshot.state)}\n本阶段累计:{actual},期望:6")
  1064. return
  1065. self.auto_phase = "reverse"
  1066. self.run_baseline = snapshot.total_pulses
  1067. self.operation_var.set("灯测 3/3:反向 6 脉冲 / 2 Hz")
  1068. self._submit("start", purpose="auto_reverse", pulses=6, frequency=2,
  1069. direction=1, restart=True)
  1070. elif self.auto_phase == "reverse" and snapshot.state in (5, 6, 7):
  1071. actual = abs(snapshot.total_pulses - self.run_baseline)
  1072. self.auto_phase = None
  1073. if snapshot.state == 5 and actual == 6 and snapshot.total_pulses == self.lamp_test_baseline:
  1074. self.operation_var.set("低速正反转灯测通过:正向和反向各 6 脉冲")
  1075. messagebox.showinfo("灯测完成", "软件计数验证通过:正向 6 脉冲,反向 6 脉冲。\n请同时确认反向时脉冲灯仍亮灭,方向点电平已改变。")
  1076. else:
  1077. messagebox.showerror("反向灯测失败", f"状态:{STATE_TEXT.get(snapshot.state, snapshot.state)}\n反向阶段脉冲:{actual},期望:6\n灯测前累计:{self.lamp_test_baseline},灯测后累计:{snapshot.total_pulses}")
  1078. def _update_progress(self, snapshot: Snapshot) -> None:
  1079. if self.run_target <= 0:
  1080. return
  1081. done = abs(snapshot.total_pulses - self.run_baseline)
  1082. percent = min(100.0, done * 100.0 / self.run_target)
  1083. self.progress.configure(value=percent)
  1084. self.progress_text_var.set(f"{min(done, self.run_target):,} / {self.run_target:,}({percent:.0f}%)")
  1085. def _reset_progress(self) -> None:
  1086. self.progress.configure(value=0)
  1087. self.progress_text_var.set(f"0 / {self.run_target:,}" if self.run_target else "0 / 0")
  1088. def _show_raw_registers(self, values: dict[str, list[int]]) -> None:
  1089. self.register_tree.delete(*self.register_tree.get_children())
  1090. if "status" in values:
  1091. for address, value in enumerate(values["status"]):
  1092. self.register_tree.insert("", "end", values=(address, f"0x{value:04X}", value, REGISTER_NAMES.get(address, "保留/未使用")))
  1093. for offset, value in enumerate(values["control"]):
  1094. address = 100 + offset
  1095. self.register_tree.insert("", "end", values=(address, f"0x{value:04X}", value, REGISTER_NAMES.get(address, "保留/未使用")))
  1096. return
  1097. for offset, value in enumerate(values["config"]):
  1098. address = 0x1000 + offset
  1099. self.register_tree.insert("", "end", values=(f"0x{address:04X}", f"0x{value:04X}", value, SPEC_REGISTER_NAMES.get(address, "配置/保留")))
  1100. for index, segment in enumerate(values["segments"], 1):
  1101. base = REG_SEGMENT_BASE + (index - 1) * 0x10
  1102. for offset, value in enumerate(segment):
  1103. address = base + offset
  1104. self.register_tree.insert("", "end", values=(f"0x{address:04X}", f"0x{value:04X}", value, f"第{index}段 / 偏移 {offset}"))
  1105. for offset, value in enumerate(values["monitor"]):
  1106. address = REG_MONITOR_TOTAL_PULSES + offset
  1107. self.register_tree.insert("", "end", values=(f"0x{address:04X}", f"0x{value:04X}", value, SPEC_REGISTER_NAMES.get(address, "监控/保留")))
  1108. value = values["control"][0]
  1109. self.register_tree.insert("", "end", values=("0x3000", f"0x{value:04X}", value, SPEC_REGISTER_NAMES[0x3000]))
  1110. def _poll_tick(self) -> None:
  1111. if not self.closing and self.connected and not self.job_active:
  1112. self._submit("snapshot", purpose="poll")
  1113. if not self.closing:
  1114. self.after(POLL_INTERVAL_MS, self._poll_tick)
  1115. def _update_controls(self) -> None:
  1116. ui_busy = (self.job_active and not self.poll_active) or self.deferred_job is not None
  1117. connected_idle = self.connected and not ui_busy
  1118. device_running = self.last_snapshot is not None and self.last_snapshot.state in (1, 2, 3, 4)
  1119. can_start = connected_idle and not device_running
  1120. can_stop = connected_idle and device_running
  1121. self._set_widget_state(self.connect_button, "disabled" if ui_busy else "normal")
  1122. serial_state = "disabled" if self.connected or ui_busy else "readonly"
  1123. self._set_widget_state(self.port_combo, serial_state)
  1124. self._set_widget_state(self.baud_combo, serial_state)
  1125. self._set_widget_state(self.slave_spin, "disabled" if self.connected or ui_busy else "normal")
  1126. self._set_widget_state(self.refresh_button, "disabled" if self.connected or ui_busy else "normal")
  1127. for widget in (self.save_outputs_button, self.read_now_button, self.raw_button, self.reset_button, self.clear_button):
  1128. self._set_widget_state(widget, "normal" if connected_idle and not device_running else "disabled")
  1129. for widget in (self.read_config_button, self.write_config_button, self.apply_segment_button):
  1130. allowed = connected_idle and not device_running and self.protocol_var.get() == MAP_SPEC
  1131. self._set_widget_state(widget, "normal" if allowed else "disabled")
  1132. config_start_allowed = connected_idle and not device_running and self.protocol_var.get() == MAP_SPEC
  1133. config_stop_allowed = connected_idle and device_running and self.protocol_var.get() == MAP_SPEC
  1134. config_idle_allowed = connected_idle and not device_running and self.protocol_var.get() == MAP_SPEC
  1135. for widget in (self.config_start_button, self.config_restart_button):
  1136. self._set_widget_state(widget, "normal" if config_start_allowed else "disabled")
  1137. self._set_widget_state(self.config_stop_button, "normal" if config_stop_allowed else "disabled")
  1138. for widget in (self.config_emergency_button,):
  1139. self._set_widget_state(widget, "normal" if config_stop_allowed else "disabled")
  1140. for widget in (self.config_reset_button, self.config_clear_button):
  1141. self._set_widget_state(widget, "normal" if config_idle_allowed else "disabled")
  1142. self._set_widget_state(self.start_button, "normal" if can_start else "disabled")
  1143. self._set_widget_state(self.lamp_test_button, "normal" if can_start else "disabled")
  1144. self._set_widget_state(self.stop_button, "normal" if can_stop else "disabled")
  1145. self._set_widget_state(self.emergency_button, "normal" if can_start else "disabled")
  1146. config_state = "readonly" if connected_idle and not device_running else "disabled"
  1147. self._set_widget_state(self.pulse_output_combo, config_state)
  1148. self._set_widget_state(self.direction_output_combo, config_state)
  1149. motion_state = "normal" if can_start else "disabled"
  1150. self._set_widget_state(self.pulses_spin, motion_state)
  1151. self._set_widget_state(
  1152. self.frequency_spin, "normal" if connected_idle else "disabled"
  1153. )
  1154. self._set_widget_state(
  1155. self.apply_frequency_button,
  1156. "normal" if connected_idle and device_running else "disabled",
  1157. )
  1158. self._set_widget_state(self.forward_radio, motion_state)
  1159. self._set_widget_state(self.reverse_radio, motion_state)
  1160. @staticmethod
  1161. def _set_widget_state(widget: ttk.Widget, state: str) -> None:
  1162. if str(widget.cget("state")) != state:
  1163. widget.configure(state=state)
  1164. def _set_disconnected(self, message: str) -> None:
  1165. self.connected = False
  1166. self.job_active = False
  1167. self.poll_active = False
  1168. self.deferred_job = None
  1169. self.auto_phase = None
  1170. self.last_snapshot = None
  1171. self.connection_var.set(message)
  1172. self.connection_label.configure(style="StatusOff.TLabel")
  1173. self.connect_button.configure(text="连接设备")
  1174. self.operation_var.set(message)
  1175. self._update_controls()
  1176. def _append_log(self, line: str) -> None:
  1177. self.log_text.configure(state="normal")
  1178. self.log_text.insert("end", line + "\n")
  1179. self.log_line_count += 1
  1180. if self.log_line_count > MAX_LOG_LINES:
  1181. remove_count = min(100, self.log_line_count - MAX_LOG_LINES)
  1182. self.log_text.delete("1.0", f"{remove_count + 1}.0")
  1183. self.log_line_count -= remove_count
  1184. self.log_text.see("end")
  1185. self.log_text.configure(state="disabled")
  1186. def _clear_log(self) -> None:
  1187. self.log_text.configure(state="normal")
  1188. self.log_text.delete("1.0", "end")
  1189. self.log_text.configure(state="disabled")
  1190. self.log_line_count = 0
  1191. def _export_log(self) -> None:
  1192. path = filedialog.asksaveasfilename(
  1193. title="导出通信日志",
  1194. defaultextension=".log",
  1195. filetypes=(("日志文件", "*.log"), ("文本文件", "*.txt"), ("所有文件", "*.*")),
  1196. initialfile=f"plsr-{time.strftime('%Y%m%d-%H%M%S')}.log",
  1197. )
  1198. if not path:
  1199. return
  1200. Path(path).write_text(self.log_text.get("1.0", "end-1c"), encoding="utf-8")
  1201. self.operation_var.set(f"日志已导出:{path}")
  1202. def _on_close(self) -> None:
  1203. if self.closing:
  1204. return
  1205. self.closing = True
  1206. self.jobs.put({"name": "shutdown"})
  1207. self.after(150, self.destroy)
  1208. def main() -> None:
  1209. app = PlsrGui()
  1210. app.mainloop()
  1211. if __name__ == "__main__":
  1212. main()