|
|
@@ -1,493 +0,0 @@ |
|
|
#!/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("<H", crc16_modbus(payload)) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_frame(frame: bytes, station: int, function_code: int) -> None: |
|
|
|
|
|
if len(frame) < 5: |
|
|
|
|
|
raise ModbusError("响应帧长度不足") |
|
|
|
|
|
received_crc = struct.unpack("<H", frame[-2:])[0] |
|
|
|
|
|
if crc16_modbus(frame[:-2]) != received_crc: |
|
|
|
|
|
raise ModbusError("响应 CRC 校验失败") |
|
|
|
|
|
if frame[0] != station: |
|
|
|
|
|
raise ModbusError(f"响应站号错误:期望 {station},实际 {frame[0]}") |
|
|
|
|
|
if frame[1] == (function_code | 0x80): |
|
|
|
|
|
code = frame[2] |
|
|
|
|
|
raise ModbusError(f"PLC 返回 Modbus 异常 0x{code:02X}") |
|
|
|
|
|
if frame[1] != function_code: |
|
|
|
|
|
raise ModbusError(f"响应功能码错误:期望 0x{function_code:02X},实际 0x{frame[1]:02X}") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ModbusRtuClient: |
|
|
|
|
|
"""只提供本工具所需的 Modbus RTU 读写能力。""" |
|
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> 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() |
|
|
|