综合平台编程器项目的远程存储
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

494 rivejä
22 KiB

  1. #!/usr/bin/env python3
  2. """XDH-60T4-E Modbus RTU 调试工具。
  3. 依赖:pyserial(pip install pyserial)
  4. 用途:通过 USB 转 RS-485 连接 PLC COM2,读写项目约定的 M0~M4000、D0~D4000。
  5. """
  6. from __future__ import annotations
  7. import queue
  8. import struct
  9. import threading
  10. import time
  11. import tkinter as tk
  12. from dataclasses import dataclass
  13. from tkinter import messagebox, ttk
  14. from typing import Any
  15. try:
  16. import serial
  17. from serial.tools import list_ports
  18. except ImportError as error:
  19. raise SystemExit("缺少 pyserial。请执行:python -m pip install pyserial") from error
  20. APP_TITLE = "XDH-60T4-E Modbus RTU 联机工具"
  21. MAX_PROJECT_ADDRESS = 4000
  22. class ModbusError(Exception):
  23. """Modbus RTU 通信或协议错误。"""
  24. def crc16_modbus(payload: bytes) -> int:
  25. """计算 Modbus RTU CRC-16,返回未交换字节序的 16 位数。"""
  26. crc = 0xFFFF
  27. for byte in payload:
  28. crc ^= byte
  29. for _ in range(8):
  30. crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
  31. return crc
  32. def append_crc(payload: bytes) -> bytes:
  33. return payload + struct.pack("<H", crc16_modbus(payload))
  34. def validate_frame(frame: bytes, station: int, function_code: int) -> None:
  35. if len(frame) < 5:
  36. raise ModbusError("响应帧长度不足")
  37. received_crc = struct.unpack("<H", frame[-2:])[0]
  38. if crc16_modbus(frame[:-2]) != received_crc:
  39. raise ModbusError("响应 CRC 校验失败")
  40. if frame[0] != station:
  41. raise ModbusError(f"响应站号错误:期望 {station},实际 {frame[0]}")
  42. if frame[1] == (function_code | 0x80):
  43. code = frame[2]
  44. raise ModbusError(f"PLC 返回 Modbus 异常 0x{code:02X}")
  45. if frame[1] != function_code:
  46. raise ModbusError(f"响应功能码错误:期望 0x{function_code:02X},实际 0x{frame[1]:02X}")
  47. class ModbusRtuClient:
  48. """只提供本工具所需的 Modbus RTU 读写能力。"""
  49. def __init__(self) -> None:
  50. self._serial: serial.Serial | None = None
  51. self.station = 1
  52. @property
  53. def is_connected(self) -> bool:
  54. return self._serial is not None and self._serial.is_open
  55. def connect(self, config: dict[str, Any]) -> None:
  56. self.close()
  57. self.station = int(config["station"])
  58. self._serial = serial.Serial(
  59. port=config["port"],
  60. baudrate=int(config["baudrate"]),
  61. bytesize=int(config["bytesize"]),
  62. parity=config["parity"],
  63. stopbits=float(config["stopbits"]),
  64. timeout=float(config["timeout"]),
  65. write_timeout=float(config["timeout"]),
  66. )
  67. self._serial.reset_input_buffer()
  68. self._serial.reset_output_buffer()
  69. def close(self) -> None:
  70. if self._serial is not None:
  71. self._serial.close()
  72. self._serial = None
  73. def _read_exact(self, size: int) -> bytes:
  74. if self._serial is None:
  75. raise ModbusError("串口尚未连接")
  76. data = self._serial.read(size)
  77. if len(data) != size:
  78. raise ModbusError(f"通信超时:期望接收 {size} 字节,实际收到 {len(data)} 字节")
  79. return data
  80. def _transact(self, function_code: int, request_data: bytes, response_kind: str) -> bytes:
  81. if self._serial is None or not self._serial.is_open:
  82. raise ModbusError("串口尚未连接")
  83. request = append_crc(bytes((self.station, function_code)) + request_data)
  84. self._serial.reset_input_buffer()
  85. self._serial.write(request)
  86. self._serial.flush()
  87. header = self._read_exact(2)
  88. if header[1] == (function_code | 0x80):
  89. frame = header + self._read_exact(3)
  90. elif response_kind == "read":
  91. byte_count = self._read_exact(1)
  92. frame = header + byte_count + self._read_exact(byte_count[0] + 2)
  93. else:
  94. frame = header + self._read_exact(6)
  95. validate_frame(frame, self.station, function_code)
  96. return frame
  97. def read_coils(self, address: int, count: int) -> list[bool]:
  98. self._validate_range(address, count, maximum=2000)
  99. frame = self._transact(0x01, struct.pack(">HH", address, count), "read")
  100. byte_count = frame[2]
  101. expected_bytes = (count + 7) // 8
  102. if byte_count != expected_bytes:
  103. raise ModbusError(f"线圈响应字节数错误:期望 {expected_bytes},实际 {byte_count}")
  104. values: list[bool] = []
  105. for index in range(count):
  106. values.append(bool(frame[3 + index // 8] & (1 << (index % 8))))
  107. return values
  108. def read_holding_registers(self, address: int, count: int) -> list[int]:
  109. self._validate_range(address, count, maximum=125)
  110. frame = self._transact(0x03, struct.pack(">HH", address, count), "read")
  111. byte_count = frame[2]
  112. if byte_count != count * 2:
  113. raise ModbusError(f"寄存器响应字节数错误:期望 {count * 2},实际 {byte_count}")
  114. return [struct.unpack(">h", frame[3 + offset : 5 + offset])[0] for offset in range(0, byte_count, 2)]
  115. def write_coil(self, address: int, value: bool) -> None:
  116. self._validate_range(address, 1, maximum=1)
  117. raw_value = 0xFF00 if value else 0x0000
  118. request_data = struct.pack(">HH", address, raw_value)
  119. frame = self._transact(0x05, request_data, "write")
  120. if frame[2:6] != request_data:
  121. raise ModbusError("写线圈响应内容与请求不一致")
  122. def write_register(self, address: int, value: int) -> None:
  123. self._validate_range(address, 1, maximum=1)
  124. if not -32768 <= value <= 32767:
  125. raise ModbusError("D 寄存器值必须在 -32768 到 32767 之间")
  126. request_data = struct.pack(">Hh", address, value)
  127. frame = self._transact(0x06, request_data, "write")
  128. if frame[2:6] != request_data:
  129. raise ModbusError("写寄存器响应内容与请求不一致")
  130. @staticmethod
  131. def _validate_range(address: int, count: int, maximum: int) -> None:
  132. if not 0 <= address <= MAX_PROJECT_ADDRESS:
  133. raise ModbusError(f"地址必须在 0 到 {MAX_PROJECT_ADDRESS} 之间")
  134. if not 1 <= count <= maximum:
  135. raise ModbusError(f"读取数量必须在 1 到 {maximum} 之间")
  136. if address + count - 1 > MAX_PROJECT_ADDRESS:
  137. raise ModbusError(f"地址范围不能超过 {MAX_PROJECT_ADDRESS}")
  138. @dataclass(frozen=True)
  139. class Command:
  140. request_id: int
  141. action: str
  142. payload: dict[str, Any]
  143. class CommunicationWorker:
  144. """独占串口的后台线程,保证 GUI 不因通信超时而冻结。"""
  145. def __init__(self) -> None:
  146. self.commands: queue.Queue[Command] = queue.Queue()
  147. self.results: queue.Queue[tuple[int, bool, str, Any]] = queue.Queue()
  148. self._stop_event = threading.Event()
  149. self._thread = threading.Thread(target=self._run, name="modbus-rtu-worker", daemon=True)
  150. self._thread.start()
  151. def submit(self, command: Command) -> None:
  152. self.commands.put(command)
  153. def shutdown(self) -> None:
  154. self._stop_event.set()
  155. self.commands.put(Command(0, "shutdown", {}))
  156. self._thread.join(timeout=1.5)
  157. def _run(self) -> None:
  158. client = ModbusRtuClient()
  159. while not self._stop_event.is_set():
  160. try:
  161. command = self.commands.get(timeout=0.2)
  162. except queue.Empty:
  163. continue
  164. try:
  165. if command.action == "shutdown":
  166. client.close()
  167. return
  168. if command.action == "connect":
  169. client.connect(command.payload)
  170. result = f"已连接 {command.payload['port']},站号 {client.station}"
  171. elif command.action == "disconnect":
  172. client.close()
  173. result = "串口已断开"
  174. elif command.action == "read_m":
  175. result = client.read_coils(command.payload["address"], command.payload["count"])
  176. elif command.action == "read_d":
  177. result = client.read_holding_registers(command.payload["address"], command.payload["count"])
  178. elif command.action == "write_m":
  179. client.write_coil(command.payload["address"], command.payload["value"])
  180. result = None
  181. elif command.action == "write_d":
  182. client.write_register(command.payload["address"], command.payload["value"])
  183. result = None
  184. else:
  185. raise ModbusError(f"未知操作:{command.action}")
  186. self.results.put((command.request_id, True, command.action, result))
  187. except (ModbusError, serial.SerialException, ValueError, struct.error) as error:
  188. self.results.put((command.request_id, False, command.action, str(error)))
  189. class PlcToolApp(tk.Tk):
  190. def __init__(self) -> None:
  191. super().__init__()
  192. self.title(APP_TITLE)
  193. self.minsize(840, 600)
  194. self.geometry("920x680")
  195. self.worker = CommunicationWorker()
  196. self._next_request_id = 1
  197. self._request_labels: dict[int, str] = {}
  198. self.port_var = tk.StringVar()
  199. self.baudrate_var = tk.StringVar(value="9600")
  200. self.bytesize_var = tk.StringVar(value="8")
  201. self.parity_var = tk.StringVar(value="N")
  202. self.stopbits_var = tk.StringVar(value="1")
  203. self.station_var = tk.StringVar(value="1")
  204. self.timeout_var = tk.StringVar(value="1.0")
  205. self.status_var = tk.StringVar(value="未连接")
  206. self.read_m_address_var = tk.StringVar(value="0")
  207. self.read_m_count_var = tk.StringVar(value="1")
  208. self.read_d_address_var = tk.StringVar(value="0")
  209. self.read_d_count_var = tk.StringVar(value="1")
  210. self.write_m_address_var = tk.StringVar(value="0")
  211. self.write_m_value_var = tk.BooleanVar(value=False)
  212. self.write_d_address_var = tk.StringVar(value="0")
  213. self.write_d_value_var = tk.StringVar(value="0")
  214. self._build_ui()
  215. self.refresh_ports()
  216. self.protocol("WM_DELETE_WINDOW", self.on_close)
  217. self.after(80, self.process_results)
  218. def _build_ui(self) -> None:
  219. root = ttk.Frame(self, padding=12)
  220. root.grid(sticky="nsew")
  221. self.columnconfigure(0, weight=1)
  222. self.rowconfigure(0, weight=1)
  223. root.columnconfigure(0, weight=1)
  224. root.rowconfigure(3, weight=1)
  225. connection = ttk.LabelFrame(root, text="连接参数", padding=10)
  226. connection.grid(row=0, column=0, sticky="ew")
  227. for column in range(8):
  228. connection.columnconfigure(column, weight=1 if column in (1, 3, 5) else 0)
  229. self._add_label_entry(connection, "串口", self.port_var, 0, 0, width=14, readonly=True)
  230. self.port_combo = connection.grid_slaves(row=0, column=1)[0]
  231. ttk.Button(connection, text="刷新", command=self.refresh_ports).grid(row=0, column=2, padx=(6, 12))
  232. self._add_label_entry(connection, "波特率", self.baudrate_var, 0, 3, width=10, values=("9600", "19200", "38400", "57600", "115200"))
  233. self._add_label_entry(connection, "站号", self.station_var, 0, 5, width=6)
  234. ttk.Button(connection, text="连接", command=self.connect).grid(row=0, column=7, padx=(12, 4))
  235. self._add_label_entry(connection, "数据位", self.bytesize_var, 1, 0, width=8, values=("7", "8"))
  236. self._add_label_entry(connection, "校验", self.parity_var, 1, 2, width=8, values=("N", "E", "O"))
  237. self._add_label_entry(connection, "停止位", self.stopbits_var, 1, 4, width=8, values=("1", "1.5", "2"))
  238. self._add_label_entry(connection, "超时(秒)", self.timeout_var, 1, 6, width=8)
  239. ttk.Button(connection, text="断开", command=lambda: self.submit("disconnect", {}, "断开")).grid(row=1, column=7, padx=(12, 4))
  240. operations = ttk.Frame(root)
  241. operations.grid(row=1, column=0, sticky="ew", pady=(12, 0))
  242. operations.columnconfigure(0, weight=1)
  243. operations.columnconfigure(1, weight=1)
  244. self._build_read_panel(operations)
  245. self._build_write_panel(operations)
  246. result_frame = ttk.LabelFrame(root, text="读取结果", padding=8)
  247. result_frame.grid(row=2, column=0, sticky="nsew", pady=(12, 0))
  248. result_frame.columnconfigure(0, weight=1)
  249. self.result_text = tk.Text(result_frame, height=8, wrap="word", state="disabled", font=("Consolas", 10))
  250. self.result_text.grid(row=0, column=0, sticky="nsew")
  251. log_frame = ttk.LabelFrame(root, text="通信日志", padding=8)
  252. log_frame.grid(row=3, column=0, sticky="nsew", pady=(12, 0))
  253. log_frame.columnconfigure(0, weight=1)
  254. log_frame.rowconfigure(0, weight=1)
  255. self.log_text = tk.Text(log_frame, height=10, wrap="word", state="disabled", font=("Consolas", 10))
  256. self.log_text.grid(row=0, column=0, sticky="nsew")
  257. ttk.Button(log_frame, text="清空日志", command=lambda: self._set_text(self.log_text, "")).grid(row=1, column=0, sticky="e", pady=(6, 0))
  258. status = ttk.Label(root, textvariable=self.status_var, relief="sunken", anchor="w", padding=(7, 3))
  259. status.grid(row=4, column=0, sticky="ew", pady=(10, 0))
  260. @staticmethod
  261. def _add_label_entry(
  262. parent: ttk.Widget,
  263. label: str,
  264. variable: tk.StringVar,
  265. row: int,
  266. column: int,
  267. width: int,
  268. values: tuple[str, ...] | None = None,
  269. readonly: bool = False,
  270. ) -> None:
  271. ttk.Label(parent, text=label).grid(row=row, column=column, sticky="w", padx=(0 if column == 0 else 10, 4), pady=3)
  272. if values is not None or readonly:
  273. state = "readonly" if readonly else "normal"
  274. widget = ttk.Combobox(parent, textvariable=variable, values=values, width=width, state=state)
  275. else:
  276. widget = ttk.Entry(parent, textvariable=variable, width=width)
  277. widget.grid(row=row, column=column + 1, sticky="ew", pady=3)
  278. def _build_read_panel(self, parent: ttk.Frame) -> None:
  279. panel = ttk.LabelFrame(parent, text="读取 PLC", padding=10)
  280. panel.grid(row=0, column=0, sticky="nsew", padx=(0, 6))
  281. ttk.Label(panel, text="M 地址").grid(row=0, column=0, sticky="w")
  282. ttk.Entry(panel, textvariable=self.read_m_address_var, width=9).grid(row=0, column=1, padx=5)
  283. ttk.Label(panel, text="数量").grid(row=0, column=2, sticky="w")
  284. ttk.Entry(panel, textvariable=self.read_m_count_var, width=7).grid(row=0, column=3, padx=5)
  285. ttk.Button(panel, text="读取 M", command=self.read_m).grid(row=0, column=4, padx=(8, 0))
  286. ttk.Label(panel, text="D 地址").grid(row=1, column=0, sticky="w", pady=(8, 0))
  287. ttk.Entry(panel, textvariable=self.read_d_address_var, width=9).grid(row=1, column=1, padx=5, pady=(8, 0))
  288. ttk.Label(panel, text="数量").grid(row=1, column=2, sticky="w", pady=(8, 0))
  289. ttk.Entry(panel, textvariable=self.read_d_count_var, width=7).grid(row=1, column=3, padx=5, pady=(8, 0))
  290. ttk.Button(panel, text="读取 D", command=self.read_d).grid(row=1, column=4, padx=(8, 0), pady=(8, 0))
  291. def _build_write_panel(self, parent: ttk.Frame) -> None:
  292. panel = ttk.LabelFrame(parent, text="写入 PLC(请先确认测试程序安全)", padding=10)
  293. panel.grid(row=0, column=1, sticky="nsew", padx=(6, 0))
  294. ttk.Label(panel, text="M 地址").grid(row=0, column=0, sticky="w")
  295. ttk.Entry(panel, textvariable=self.write_m_address_var, width=9).grid(row=0, column=1, padx=5)
  296. ttk.Checkbutton(panel, text="写入 ON", variable=self.write_m_value_var).grid(row=0, column=2, padx=5)
  297. ttk.Button(panel, text="写 M", command=self.write_m).grid(row=0, column=3, padx=(8, 0))
  298. ttk.Label(panel, text="D 地址").grid(row=1, column=0, sticky="w", pady=(8, 0))
  299. ttk.Entry(panel, textvariable=self.write_d_address_var, width=9).grid(row=1, column=1, padx=5, pady=(8, 0))
  300. ttk.Entry(panel, textvariable=self.write_d_value_var, width=10).grid(row=1, column=2, padx=5, pady=(8, 0))
  301. ttk.Button(panel, text="写 D", command=self.write_d).grid(row=1, column=3, padx=(8, 0), pady=(8, 0))
  302. def refresh_ports(self) -> None:
  303. ports = [port.device for port in list_ports.comports()]
  304. self.port_combo["values"] = ports
  305. if ports and self.port_var.get() not in ports:
  306. self.port_var.set(ports[0])
  307. self.log(f"检测到串口:{', '.join(ports) if ports else '无'}")
  308. def connect(self) -> None:
  309. try:
  310. config = {
  311. "port": self.port_var.get().strip(),
  312. "baudrate": int(self.baudrate_var.get()),
  313. "bytesize": int(self.bytesize_var.get()),
  314. "parity": self.parity_var.get().strip().upper(),
  315. "stopbits": float(self.stopbits_var.get()),
  316. "station": int(self.station_var.get()),
  317. "timeout": float(self.timeout_var.get()),
  318. }
  319. if not config["port"]:
  320. raise ValueError("请选择串口")
  321. if not 1 <= config["station"] <= 247:
  322. raise ValueError("站号必须在 1 到 247 之间")
  323. if config["timeout"] <= 0:
  324. raise ValueError("超时必须大于 0")
  325. if config["parity"] not in ("N", "E", "O"):
  326. raise ValueError("校验仅支持 N、E 或 O")
  327. except ValueError as error:
  328. self.show_input_error(str(error))
  329. return
  330. self.submit("connect", config, "连接")
  331. def read_m(self) -> None:
  332. payload = self.parse_address_count(self.read_m_address_var, self.read_m_count_var)
  333. if payload is not None:
  334. self.submit("read_m", payload, f"读取 M{payload['address']} 起 {payload['count']} 个")
  335. def read_d(self) -> None:
  336. payload = self.parse_address_count(self.read_d_address_var, self.read_d_count_var)
  337. if payload is not None:
  338. self.submit("read_d", payload, f"读取 D{payload['address']} 起 {payload['count']} 个")
  339. def write_m(self) -> None:
  340. try:
  341. address = int(self.write_m_address_var.get())
  342. except ValueError:
  343. self.show_input_error("M 地址必须是整数")
  344. return
  345. self.submit("write_m", {"address": address, "value": self.write_m_value_var.get()}, f"写入 M{address}")
  346. def write_d(self) -> None:
  347. try:
  348. address = int(self.write_d_address_var.get())
  349. value = int(self.write_d_value_var.get())
  350. except ValueError:
  351. self.show_input_error("D 地址和值必须是整数")
  352. return
  353. self.submit("write_d", {"address": address, "value": value}, f"写入 D{address}={value}")
  354. def parse_address_count(self, address_var: tk.StringVar, count_var: tk.StringVar) -> dict[str, int] | None:
  355. try:
  356. return {"address": int(address_var.get()), "count": int(count_var.get())}
  357. except ValueError:
  358. self.show_input_error("地址和数量必须是整数")
  359. return None
  360. def submit(self, action: str, payload: dict[str, Any], label: str) -> None:
  361. request_id = self._next_request_id
  362. self._next_request_id += 1
  363. self._request_labels[request_id] = label
  364. self.worker.submit(Command(request_id, action, payload))
  365. self.status_var.set(f"处理中:{label}")
  366. self.log(f"发送请求:{label}")
  367. def process_results(self) -> None:
  368. while True:
  369. try:
  370. request_id, success, action, result = self.worker.results.get_nowait()
  371. except queue.Empty:
  372. break
  373. label = self._request_labels.pop(request_id, action)
  374. if success:
  375. self.status_var.set(f"成功:{label}")
  376. self.log(f"成功:{label}")
  377. if action == "connect":
  378. self.status_var.set(result)
  379. self.log(result)
  380. elif action == "read_m":
  381. self.show_read_result("M", self.read_m_address_var.get(), result)
  382. elif action == "read_d":
  383. self.show_read_result("D", self.read_d_address_var.get(), result)
  384. elif action == "write_m":
  385. self.log("写入完成;请使用“读取 M”确认 PLC 实际状态。")
  386. elif action == "write_d":
  387. self.log("写入完成;请使用“读取 D”确认 PLC 实际值。")
  388. else:
  389. self.status_var.set(f"失败:{label}")
  390. self.log(f"失败:{label}。原因:{result}")
  391. self.after(80, self.process_results)
  392. def show_read_result(self, area: str, address_text: str, values: list[bool] | list[int]) -> None:
  393. start_address = int(address_text)
  394. lines = [f"{area} 区读取结果,共 {len(values)} 项:"]
  395. for index, value in enumerate(values):
  396. if area == "M":
  397. rendered = "ON" if value else "OFF"
  398. else:
  399. rendered = str(value)
  400. lines.append(f"{area}{start_address + index} = {rendered}")
  401. self._set_text(self.result_text, "\n".join(lines))
  402. def log(self, message: str) -> None:
  403. timestamp = time.strftime("%H:%M:%S")
  404. self.log_text.configure(state="normal")
  405. self.log_text.insert("end", f"[{timestamp}] {message}\n")
  406. self.log_text.see("end")
  407. self.log_text.configure(state="disabled")
  408. @staticmethod
  409. def _set_text(widget: tk.Text, value: str) -> None:
  410. widget.configure(state="normal")
  411. widget.delete("1.0", "end")
  412. widget.insert("1.0", value)
  413. widget.configure(state="disabled")
  414. def show_input_error(self, message: str) -> None:
  415. self.status_var.set(f"输入错误:{message}")
  416. messagebox.showerror(APP_TITLE, message, parent=self)
  417. def on_close(self) -> None:
  418. self.worker.shutdown()
  419. self.destroy()
  420. if __name__ == "__main__":
  421. PlcToolApp().mainloop()