#!/usr/bin/env python3 """XDH-60T4-E Modbus RTU 调试工具。 依赖:pyserial(pip install pyserial) 用途:通过 USB 转 RS-485 连接 PLC COM2,读写项目约定的 M0~M4000、D0~D4000。 """ from __future__ import annotations import queue import struct import threading import time import tkinter as tk from dataclasses import dataclass from tkinter import messagebox, ttk from typing import Any try: import serial from serial.tools import list_ports except ImportError as error: raise SystemExit("缺少 pyserial。请执行:python -m pip install pyserial") from error APP_TITLE = "XDH-60T4-E Modbus RTU 联机工具" MAX_PROJECT_ADDRESS = 4000 class ModbusError(Exception): """Modbus RTU 通信或协议错误。""" def crc16_modbus(payload: bytes) -> int: """计算 Modbus RTU CRC-16,返回未交换字节序的 16 位数。""" crc = 0xFFFF for byte in payload: crc ^= byte for _ in range(8): crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1 return crc def append_crc(payload: bytes) -> bytes: return payload + struct.pack(" None: if len(frame) < 5: raise ModbusError("响应帧长度不足") received_crc = struct.unpack(" None: self._serial: serial.Serial | None = None self.station = 1 @property def is_connected(self) -> bool: return self._serial is not None and self._serial.is_open def connect(self, config: dict[str, Any]) -> None: self.close() self.station = int(config["station"]) self._serial = serial.Serial( port=config["port"], baudrate=int(config["baudrate"]), bytesize=int(config["bytesize"]), parity=config["parity"], stopbits=float(config["stopbits"]), timeout=float(config["timeout"]), write_timeout=float(config["timeout"]), ) self._serial.reset_input_buffer() self._serial.reset_output_buffer() def close(self) -> None: if self._serial is not None: self._serial.close() self._serial = None def _read_exact(self, size: int) -> bytes: if self._serial is None: raise ModbusError("串口尚未连接") data = self._serial.read(size) if len(data) != size: raise ModbusError(f"通信超时:期望接收 {size} 字节,实际收到 {len(data)} 字节") return data def _transact(self, function_code: int, request_data: bytes, response_kind: str) -> bytes: if self._serial is None or not self._serial.is_open: raise ModbusError("串口尚未连接") request = append_crc(bytes((self.station, function_code)) + request_data) self._serial.reset_input_buffer() self._serial.write(request) self._serial.flush() header = self._read_exact(2) if header[1] == (function_code | 0x80): frame = header + self._read_exact(3) elif response_kind == "read": byte_count = self._read_exact(1) frame = header + byte_count + self._read_exact(byte_count[0] + 2) else: frame = header + self._read_exact(6) validate_frame(frame, self.station, function_code) return frame def read_coils(self, address: int, count: int) -> list[bool]: self._validate_range(address, count, maximum=2000) frame = self._transact(0x01, struct.pack(">HH", address, count), "read") byte_count = frame[2] expected_bytes = (count + 7) // 8 if byte_count != expected_bytes: raise ModbusError(f"线圈响应字节数错误:期望 {expected_bytes},实际 {byte_count}") values: list[bool] = [] for index in range(count): values.append(bool(frame[3 + index // 8] & (1 << (index % 8)))) return values def read_holding_registers(self, address: int, count: int) -> list[int]: self._validate_range(address, count, maximum=125) frame = self._transact(0x03, struct.pack(">HH", address, count), "read") byte_count = frame[2] if byte_count != count * 2: raise ModbusError(f"寄存器响应字节数错误:期望 {count * 2},实际 {byte_count}") return [struct.unpack(">h", frame[3 + offset : 5 + offset])[0] for offset in range(0, byte_count, 2)] def write_coil(self, address: int, value: bool) -> None: self._validate_range(address, 1, maximum=1) raw_value = 0xFF00 if value else 0x0000 request_data = struct.pack(">HH", address, raw_value) frame = self._transact(0x05, request_data, "write") if frame[2:6] != request_data: raise ModbusError("写线圈响应内容与请求不一致") def write_register(self, address: int, value: int) -> None: self._validate_range(address, 1, maximum=1) if not -32768 <= value <= 32767: raise ModbusError("D 寄存器值必须在 -32768 到 32767 之间") request_data = struct.pack(">Hh", address, value) frame = self._transact(0x06, request_data, "write") if frame[2:6] != request_data: raise ModbusError("写寄存器响应内容与请求不一致") @staticmethod def _validate_range(address: int, count: int, maximum: int) -> None: if not 0 <= address <= MAX_PROJECT_ADDRESS: raise ModbusError(f"地址必须在 0 到 {MAX_PROJECT_ADDRESS} 之间") if not 1 <= count <= maximum: raise ModbusError(f"读取数量必须在 1 到 {maximum} 之间") if address + count - 1 > MAX_PROJECT_ADDRESS: raise ModbusError(f"地址范围不能超过 {MAX_PROJECT_ADDRESS}") @dataclass(frozen=True) class Command: request_id: int action: str payload: dict[str, Any] class CommunicationWorker: """独占串口的后台线程,保证 GUI 不因通信超时而冻结。""" def __init__(self) -> None: self.commands: queue.Queue[Command] = queue.Queue() self.results: queue.Queue[tuple[int, bool, str, Any]] = queue.Queue() self._stop_event = threading.Event() self._thread = threading.Thread(target=self._run, name="modbus-rtu-worker", daemon=True) self._thread.start() def submit(self, command: Command) -> None: self.commands.put(command) def shutdown(self) -> None: self._stop_event.set() self.commands.put(Command(0, "shutdown", {})) self._thread.join(timeout=1.5) def _run(self) -> None: client = ModbusRtuClient() while not self._stop_event.is_set(): try: command = self.commands.get(timeout=0.2) except queue.Empty: continue try: if command.action == "shutdown": client.close() return if command.action == "connect": client.connect(command.payload) result = f"已连接 {command.payload['port']},站号 {client.station}" elif command.action == "disconnect": client.close() result = "串口已断开" elif command.action == "read_m": result = client.read_coils(command.payload["address"], command.payload["count"]) elif command.action == "read_d": result = client.read_holding_registers(command.payload["address"], command.payload["count"]) elif command.action == "write_m": client.write_coil(command.payload["address"], command.payload["value"]) result = None elif command.action == "write_d": client.write_register(command.payload["address"], command.payload["value"]) result = None else: raise ModbusError(f"未知操作:{command.action}") self.results.put((command.request_id, True, command.action, result)) except (ModbusError, serial.SerialException, ValueError, struct.error) as error: self.results.put((command.request_id, False, command.action, str(error))) class PlcToolApp(tk.Tk): def __init__(self) -> None: super().__init__() self.title(APP_TITLE) self.minsize(840, 600) self.geometry("920x680") self.worker = CommunicationWorker() self._next_request_id = 1 self._request_labels: dict[int, str] = {} self.port_var = tk.StringVar() self.baudrate_var = tk.StringVar(value="9600") self.bytesize_var = tk.StringVar(value="8") self.parity_var = tk.StringVar(value="N") self.stopbits_var = tk.StringVar(value="1") self.station_var = tk.StringVar(value="1") self.timeout_var = tk.StringVar(value="1.0") self.status_var = tk.StringVar(value="未连接") self.read_m_address_var = tk.StringVar(value="0") self.read_m_count_var = tk.StringVar(value="1") self.read_d_address_var = tk.StringVar(value="0") self.read_d_count_var = tk.StringVar(value="1") self.write_m_address_var = tk.StringVar(value="0") self.write_m_value_var = tk.BooleanVar(value=False) self.write_d_address_var = tk.StringVar(value="0") self.write_d_value_var = tk.StringVar(value="0") self._build_ui() self.refresh_ports() self.protocol("WM_DELETE_WINDOW", self.on_close) self.after(80, self.process_results) def _build_ui(self) -> None: root = ttk.Frame(self, padding=12) root.grid(sticky="nsew") self.columnconfigure(0, weight=1) self.rowconfigure(0, weight=1) root.columnconfigure(0, weight=1) root.rowconfigure(3, weight=1) connection = ttk.LabelFrame(root, text="连接参数", padding=10) connection.grid(row=0, column=0, sticky="ew") for column in range(8): connection.columnconfigure(column, weight=1 if column in (1, 3, 5) else 0) self._add_label_entry(connection, "串口", self.port_var, 0, 0, width=14, readonly=True) self.port_combo = connection.grid_slaves(row=0, column=1)[0] ttk.Button(connection, text="刷新", command=self.refresh_ports).grid(row=0, column=2, padx=(6, 12)) self._add_label_entry(connection, "波特率", self.baudrate_var, 0, 3, width=10, values=("9600", "19200", "38400", "57600", "115200")) self._add_label_entry(connection, "站号", self.station_var, 0, 5, width=6) ttk.Button(connection, text="连接", command=self.connect).grid(row=0, column=7, padx=(12, 4)) self._add_label_entry(connection, "数据位", self.bytesize_var, 1, 0, width=8, values=("7", "8")) self._add_label_entry(connection, "校验", self.parity_var, 1, 2, width=8, values=("N", "E", "O")) self._add_label_entry(connection, "停止位", self.stopbits_var, 1, 4, width=8, values=("1", "1.5", "2")) self._add_label_entry(connection, "超时(秒)", self.timeout_var, 1, 6, width=8) ttk.Button(connection, text="断开", command=lambda: self.submit("disconnect", {}, "断开")).grid(row=1, column=7, padx=(12, 4)) operations = ttk.Frame(root) operations.grid(row=1, column=0, sticky="ew", pady=(12, 0)) operations.columnconfigure(0, weight=1) operations.columnconfigure(1, weight=1) self._build_read_panel(operations) self._build_write_panel(operations) result_frame = ttk.LabelFrame(root, text="读取结果", padding=8) result_frame.grid(row=2, column=0, sticky="nsew", pady=(12, 0)) result_frame.columnconfigure(0, weight=1) self.result_text = tk.Text(result_frame, height=8, wrap="word", state="disabled", font=("Consolas", 10)) self.result_text.grid(row=0, column=0, sticky="nsew") log_frame = ttk.LabelFrame(root, text="通信日志", padding=8) log_frame.grid(row=3, column=0, sticky="nsew", pady=(12, 0)) log_frame.columnconfigure(0, weight=1) log_frame.rowconfigure(0, weight=1) self.log_text = tk.Text(log_frame, height=10, wrap="word", state="disabled", font=("Consolas", 10)) self.log_text.grid(row=0, column=0, sticky="nsew") ttk.Button(log_frame, text="清空日志", command=lambda: self._set_text(self.log_text, "")).grid(row=1, column=0, sticky="e", pady=(6, 0)) status = ttk.Label(root, textvariable=self.status_var, relief="sunken", anchor="w", padding=(7, 3)) status.grid(row=4, column=0, sticky="ew", pady=(10, 0)) @staticmethod def _add_label_entry( parent: ttk.Widget, label: str, variable: tk.StringVar, row: int, column: int, width: int, values: tuple[str, ...] | None = None, readonly: bool = False, ) -> None: ttk.Label(parent, text=label).grid(row=row, column=column, sticky="w", padx=(0 if column == 0 else 10, 4), pady=3) if values is not None or readonly: state = "readonly" if readonly else "normal" widget = ttk.Combobox(parent, textvariable=variable, values=values, width=width, state=state) else: widget = ttk.Entry(parent, textvariable=variable, width=width) widget.grid(row=row, column=column + 1, sticky="ew", pady=3) def _build_read_panel(self, parent: ttk.Frame) -> None: panel = ttk.LabelFrame(parent, text="读取 PLC", padding=10) panel.grid(row=0, column=0, sticky="nsew", padx=(0, 6)) ttk.Label(panel, text="M 地址").grid(row=0, column=0, sticky="w") ttk.Entry(panel, textvariable=self.read_m_address_var, width=9).grid(row=0, column=1, padx=5) ttk.Label(panel, text="数量").grid(row=0, column=2, sticky="w") ttk.Entry(panel, textvariable=self.read_m_count_var, width=7).grid(row=0, column=3, padx=5) ttk.Button(panel, text="读取 M", command=self.read_m).grid(row=0, column=4, padx=(8, 0)) ttk.Label(panel, text="D 地址").grid(row=1, column=0, sticky="w", pady=(8, 0)) ttk.Entry(panel, textvariable=self.read_d_address_var, width=9).grid(row=1, column=1, padx=5, pady=(8, 0)) ttk.Label(panel, text="数量").grid(row=1, column=2, sticky="w", pady=(8, 0)) ttk.Entry(panel, textvariable=self.read_d_count_var, width=7).grid(row=1, column=3, padx=5, pady=(8, 0)) ttk.Button(panel, text="读取 D", command=self.read_d).grid(row=1, column=4, padx=(8, 0), pady=(8, 0)) def _build_write_panel(self, parent: ttk.Frame) -> None: panel = ttk.LabelFrame(parent, text="写入 PLC(请先确认测试程序安全)", padding=10) panel.grid(row=0, column=1, sticky="nsew", padx=(6, 0)) ttk.Label(panel, text="M 地址").grid(row=0, column=0, sticky="w") ttk.Entry(panel, textvariable=self.write_m_address_var, width=9).grid(row=0, column=1, padx=5) ttk.Checkbutton(panel, text="写入 ON", variable=self.write_m_value_var).grid(row=0, column=2, padx=5) ttk.Button(panel, text="写 M", command=self.write_m).grid(row=0, column=3, padx=(8, 0)) ttk.Label(panel, text="D 地址").grid(row=1, column=0, sticky="w", pady=(8, 0)) ttk.Entry(panel, textvariable=self.write_d_address_var, width=9).grid(row=1, column=1, padx=5, pady=(8, 0)) ttk.Entry(panel, textvariable=self.write_d_value_var, width=10).grid(row=1, column=2, padx=5, pady=(8, 0)) ttk.Button(panel, text="写 D", command=self.write_d).grid(row=1, column=3, padx=(8, 0), pady=(8, 0)) def refresh_ports(self) -> None: ports = [port.device for port in list_ports.comports()] self.port_combo["values"] = ports if ports and self.port_var.get() not in ports: self.port_var.set(ports[0]) self.log(f"检测到串口:{', '.join(ports) if ports else '无'}") def connect(self) -> None: try: config = { "port": self.port_var.get().strip(), "baudrate": int(self.baudrate_var.get()), "bytesize": int(self.bytesize_var.get()), "parity": self.parity_var.get().strip().upper(), "stopbits": float(self.stopbits_var.get()), "station": int(self.station_var.get()), "timeout": float(self.timeout_var.get()), } if not config["port"]: raise ValueError("请选择串口") if not 1 <= config["station"] <= 247: raise ValueError("站号必须在 1 到 247 之间") if config["timeout"] <= 0: raise ValueError("超时必须大于 0") if config["parity"] not in ("N", "E", "O"): raise ValueError("校验仅支持 N、E 或 O") except ValueError as error: self.show_input_error(str(error)) return self.submit("connect", config, "连接") def read_m(self) -> None: payload = self.parse_address_count(self.read_m_address_var, self.read_m_count_var) if payload is not None: self.submit("read_m", payload, f"读取 M{payload['address']} 起 {payload['count']} 个") def read_d(self) -> None: payload = self.parse_address_count(self.read_d_address_var, self.read_d_count_var) if payload is not None: self.submit("read_d", payload, f"读取 D{payload['address']} 起 {payload['count']} 个") def write_m(self) -> None: try: address = int(self.write_m_address_var.get()) except ValueError: self.show_input_error("M 地址必须是整数") return self.submit("write_m", {"address": address, "value": self.write_m_value_var.get()}, f"写入 M{address}") def write_d(self) -> None: try: address = int(self.write_d_address_var.get()) value = int(self.write_d_value_var.get()) except ValueError: self.show_input_error("D 地址和值必须是整数") return self.submit("write_d", {"address": address, "value": value}, f"写入 D{address}={value}") def parse_address_count(self, address_var: tk.StringVar, count_var: tk.StringVar) -> dict[str, int] | None: try: return {"address": int(address_var.get()), "count": int(count_var.get())} except ValueError: self.show_input_error("地址和数量必须是整数") return None def submit(self, action: str, payload: dict[str, Any], label: str) -> None: request_id = self._next_request_id self._next_request_id += 1 self._request_labels[request_id] = label self.worker.submit(Command(request_id, action, payload)) self.status_var.set(f"处理中:{label}") self.log(f"发送请求:{label}") def process_results(self) -> None: while True: try: request_id, success, action, result = self.worker.results.get_nowait() except queue.Empty: break label = self._request_labels.pop(request_id, action) if success: self.status_var.set(f"成功:{label}") self.log(f"成功:{label}") if action == "connect": self.status_var.set(result) self.log(result) elif action == "read_m": self.show_read_result("M", self.read_m_address_var.get(), result) elif action == "read_d": self.show_read_result("D", self.read_d_address_var.get(), result) elif action == "write_m": self.log("写入完成;请使用“读取 M”确认 PLC 实际状态。") elif action == "write_d": self.log("写入完成;请使用“读取 D”确认 PLC 实际值。") else: self.status_var.set(f"失败:{label}") self.log(f"失败:{label}。原因:{result}") self.after(80, self.process_results) def show_read_result(self, area: str, address_text: str, values: list[bool] | list[int]) -> None: start_address = int(address_text) lines = [f"{area} 区读取结果,共 {len(values)} 项:"] for index, value in enumerate(values): if area == "M": rendered = "ON" if value else "OFF" else: rendered = str(value) lines.append(f"{area}{start_address + index} = {rendered}") self._set_text(self.result_text, "\n".join(lines)) def log(self, message: str) -> None: timestamp = time.strftime("%H:%M:%S") self.log_text.configure(state="normal") self.log_text.insert("end", f"[{timestamp}] {message}\n") self.log_text.see("end") self.log_text.configure(state="disabled") @staticmethod def _set_text(widget: tk.Text, value: str) -> None: widget.configure(state="normal") widget.delete("1.0", "end") widget.insert("1.0", value) widget.configure(state="disabled") def show_input_error(self, message: str) -> None: self.status_var.set(f"输入错误:{message}") messagebox.showerror(APP_TITLE, message, parent=self) def on_close(self) -> None: self.worker.shutdown() self.destroy() if __name__ == "__main__": PlcToolApp().mainloop()