|
- """Modbus RTU T1.5/T3.5 test tool for the STM32 slave project."""
-
- import argparse
- import queue
- import threading
- import time
- import tkinter as tk
- from tkinter import messagebox, ttk
-
- import serial
- from serial.tools import list_ports
-
-
- BAUD_RATES = (9600, 19200, 115200)
- BITS_PER_CHARACTER = 11
- NEW_FRAME_GAP_US = 5000
-
-
- def calculate_timing(baud_rate):
- """Return character, T1.2, T1.5 and T3.5 times in microseconds."""
- character_us = round(BITS_PER_CHARACTER * 1_000_000 / baud_rate)
- t12_us = round(character_us * 1.2)
-
- if baud_rate > 19200:
- t15_us = 750
- t35_us = 1750
- else:
- t15_us = round(character_us * 1.5)
- t35_us = round(character_us * 3.5)
-
- invalid_gap_us = round((t15_us + t35_us) / 2)
- return character_us, t12_us, t15_us, t35_us, invalid_gap_us
-
-
- def modbus_crc(data):
- """Return the Modbus RTU CRC-16 value."""
- crc = 0xFFFF
-
- for value in data:
- crc ^= value
-
- for _ in range(8):
- if crc & 1:
- crc = (crc >> 1) ^ 0xA001
- else:
- crc >>= 1
-
- return crc
-
-
- def build_read_coils_request(slave, address, quantity):
- """Build an 0x01 Read Coils RTU request."""
- if not 1 <= slave <= 247:
- raise ValueError("从站地址必须为1~247")
- if not 0 <= address <= 0xFFFF:
- raise ValueError("线圈地址必须为0~65535")
- if not 1 <= quantity <= 2000:
- raise ValueError("线圈数量必须为1~2000")
- if address + quantity > 0x10000:
- raise ValueError("地址与数量之和超过65536")
-
- frame = bytearray(
- (
- slave,
- 0x01,
- address >> 8,
- address & 0xFF,
- quantity >> 8,
- quantity & 0xFF,
- )
- )
- crc = modbus_crc(frame)
- frame.extend((crc & 0xFF, crc >> 8))
- return bytes(frame)
-
-
- def send_request(
- port_name, frame, gap_us, timeout_ms, baud_rate, character_us
- ):
- """Send one RTU request and return received bytes and host timing data."""
- write_starts = []
- write_costs = []
-
- with serial.Serial(
- port=port_name,
- baudrate=baud_rate,
- bytesize=serial.EIGHTBITS,
- parity=serial.PARITY_EVEN,
- stopbits=serial.STOPBITS_ONE,
- timeout=0,
- write_timeout=1,
- ) as port:
- port.reset_input_buffer()
- port.reset_output_buffer()
-
- if gap_us is None:
- port.write(frame)
- port.flush()
- else:
- for index, value in enumerate(frame):
- write_start = time.perf_counter_ns()
- write_starts.append(write_start)
- port.write(bytes((value,)))
- port.flush()
- write_costs.append(
- (time.perf_counter_ns() - write_start) / 1000.0
- )
-
- if index + 1 < len(frame):
- # 下一次write的目标时刻=本次write开始时刻+一个字符时间
- # +目标静默时间。这样write、flush和循环开销都会被自动
- # 计入并从剩余等待时间中扣除。
- next_write_deadline = write_start + (
- gap_us + character_us
- ) * 1000
-
- while time.perf_counter_ns() < next_write_deadline:
- pass
-
- deadline = time.perf_counter() + timeout_ms / 1000.0
- response = bytearray()
-
- while time.perf_counter() < deadline:
- waiting = port.in_waiting
-
- if waiting:
- response.extend(port.read(waiting))
-
- time.sleep(0.001)
-
- waiting = port.in_waiting
- if waiting:
- response.extend(port.read(waiting))
-
- head_to_head = [
- (write_starts[index] - write_starts[index - 1]) / 1000.0
- for index in range(1, len(write_starts))
- ]
- estimated_silence = [
- interval - character_us for interval in head_to_head
- ]
- timing = {
- "baud_rate": baud_rate,
- "character_us": character_us,
- "write_costs": write_costs,
- "head_to_head": head_to_head,
- "estimated_silence": estimated_silence,
- }
- return bytes(response), timing
-
-
- def describe_response(response, slave):
- """Perform basic RTU response checks and return a readable result."""
- if not response:
- return "未收到响应"
- if len(response) < 5:
- return "响应长度不足5字节"
-
- received_crc = response[-2] | (response[-1] << 8)
- calculated_crc = modbus_crc(response[:-2])
- if received_crc != calculated_crc:
- return "响应CRC错误"
- if response[0] != slave:
- return "响应从站地址不匹配"
- if response[1] == 0x81:
- return f"从站返回异常码0x{response[2]:02X}"
- if response[1] != 0x01:
- return f"响应功能码错误:0x{response[1]:02X}"
-
- return "01响应格式及CRC正确"
-
-
- class TimingTester:
- """Tkinter user interface for the timing tests."""
-
- def __init__(self, root):
- self.root = root
- self.root.title("Modbus RTU T1.5/T3.5测试工具")
- self.root.resizable(False, False)
- self.result_queue = queue.Queue()
-
- self.port_var = tk.StringVar()
- self.baud_var = tk.StringVar(value="9600")
- self.slave_var = tk.StringVar(value="1")
- self.address_var = tk.StringVar(value="0")
- self.quantity_var = tk.StringVar(value="1")
- self.timeout_var = tk.StringVar(value="300")
- self.custom_gap_var = tk.StringVar()
- self.status_var = tk.StringVar(value="就绪")
- self._update_timing_values()
- self.custom_gap_var.set(str(self.t12_us))
-
- self._build_ui()
- self.refresh_ports()
- self.root.after(50, self._poll_result)
-
- def _build_ui(self):
- settings = ttk.LabelFrame(self.root, text="通信参数(8E1)")
- settings.grid(row=0, column=0, padx=10, pady=8, sticky="ew")
-
- ttk.Label(settings, text="串口").grid(row=0, column=0, padx=5, pady=5)
- self.port_box = ttk.Combobox(
- settings, textvariable=self.port_var, width=14, state="readonly"
- )
- self.port_box.grid(row=0, column=1, padx=5, pady=5)
- ttk.Button(settings, text="刷新", command=self.refresh_ports).grid(
- row=0, column=2, padx=5, pady=5
- )
-
- ttk.Label(settings, text="波特率").grid(row=1, column=0, padx=5, pady=5)
- self.baud_box = ttk.Combobox(
- settings,
- textvariable=self.baud_var,
- values=[str(value) for value in BAUD_RATES],
- width=14,
- state="readonly",
- )
- self.baud_box.grid(row=1, column=1, padx=5, pady=5)
- self.baud_box.bind("<<ComboboxSelected>>", self._on_baud_changed)
-
- ttk.Label(settings, text="从站地址").grid(row=2, column=0, padx=5, pady=5)
- ttk.Entry(settings, textvariable=self.slave_var, width=16).grid(
- row=2, column=1, padx=5, pady=5
- )
- ttk.Label(settings, text="起始线圈地址").grid(
- row=3, column=0, padx=5, pady=5
- )
- ttk.Entry(settings, textvariable=self.address_var, width=16).grid(
- row=3, column=1, padx=5, pady=5
- )
- ttk.Label(settings, text="线圈数量").grid(row=4, column=0, padx=5, pady=5)
- ttk.Entry(settings, textvariable=self.quantity_var, width=16).grid(
- row=4, column=1, padx=5, pady=5
- )
- ttk.Label(settings, text="接收等待/ms").grid(row=5, column=0, padx=5, pady=5)
- ttk.Entry(settings, textvariable=self.timeout_var, width=16).grid(
- row=5, column=1, padx=5, pady=5
- )
-
- timing = ttk.LabelFrame(self.root, text="定时测试")
- timing.grid(row=1, column=0, padx=10, pady=2, sticky="ew")
-
- self.timing_info = ttk.Label(timing, text=self._timing_info_text())
- self.timing_info.grid(
- row=0, column=0, columnspan=3, padx=5, pady=6
- )
-
- ttk.Button(
- timing,
- text="正常连续帧",
- command=lambda: self.start_test("正常连续帧", None),
- width=20,
- ).grid(row=1, column=0, padx=5, pady=5)
- self.t12_button = ttk.Button(
- timing,
- text=f"T1.2({self.t12_us}us)",
- command=lambda: self.start_test("T1.2有效帧", self.t12_us),
- width=20,
- )
- self.t12_button.grid(row=1, column=1, padx=5, pady=5)
- self.invalid_button = ttk.Button(
- timing,
- text=f"T1.5~T3.5({self.invalid_gap_us}us)",
- command=lambda: self.start_test(
- "T1.5至T3.5无效帧", self.invalid_gap_us
- ),
- width=20,
- )
- self.invalid_button.grid(row=2, column=0, padx=5, pady=5)
- ttk.Button(
- timing,
- text=f">T3.5({NEW_FRAME_GAP_US}us)",
- command=lambda: self.start_test(
- "超过T3.5的残帧", NEW_FRAME_GAP_US
- ),
- width=20,
- ).grid(row=2, column=1, padx=5, pady=5)
-
- ttk.Label(timing, text="自定义字节间隔/us").grid(
- row=3, column=0, padx=5, pady=5
- )
- ttk.Entry(timing, textvariable=self.custom_gap_var, width=12).grid(
- row=3, column=1, padx=5, pady=5, sticky="w"
- )
- ttk.Button(timing, text="自定义测试", command=self.start_custom_test).grid(
- row=3, column=2, padx=5, pady=5
- )
-
- output = ttk.LabelFrame(self.root, text="测试记录")
- output.grid(row=2, column=0, padx=10, pady=8, sticky="ew")
- self.log = tk.Text(output, width=76, height=16, state="disabled")
- self.log.grid(row=0, column=0, padx=5, pady=5)
- ttk.Label(self.root, textvariable=self.status_var).grid(
- row=3, column=0, padx=10, pady=(0, 8), sticky="w"
- )
-
- def _update_timing_values(self):
- baud_rate = int(self.baud_var.get())
- (
- self.character_us,
- self.t12_us,
- self.t15_us,
- self.t35_us,
- self.invalid_gap_us,
- ) = calculate_timing(baud_rate)
-
- def _timing_info_text(self):
- return (
- f"1字符={self.character_us}us,T1.5={self.t15_us}us,"
- f"T3.5={self.t35_us}us"
- )
-
- def _on_baud_changed(self, _event=None):
- self._update_timing_values()
- self.custom_gap_var.set(str(self.t12_us))
- self.timing_info.configure(text=self._timing_info_text())
- self.t12_button.configure(text=f"T1.2({self.t12_us}us)")
- self.invalid_button.configure(
- text=f"T1.5~T3.5({self.invalid_gap_us}us)"
- )
-
- def refresh_ports(self):
- ports = [item.device for item in list_ports.comports()]
- self.port_box["values"] = ports
-
- if ports and self.port_var.get() not in ports:
- self.port_var.set(ports[0])
- elif not ports:
- self.port_var.set("")
-
- @staticmethod
- def _parse_integer(value, name):
- try:
- return int(value.strip(), 0)
- except ValueError as error:
- raise ValueError(f"{name}不是有效整数") from error
-
- def start_custom_test(self):
- try:
- gap_us = self._parse_integer(self.custom_gap_var.get(), "自定义间隔")
- if gap_us < 0:
- raise ValueError("自定义间隔不能小于0")
- except ValueError as error:
- messagebox.showerror("参数错误", str(error))
- return
-
- self.start_test("自定义间隔", gap_us)
-
- def start_test(self, test_name, gap_us):
- try:
- port_name = self.port_var.get()
- if not port_name:
- raise ValueError("没有检测到串口,请连接USB转RS-485设备")
-
- slave = self._parse_integer(self.slave_var.get(), "从站地址")
- address = self._parse_integer(self.address_var.get(), "线圈地址")
- quantity = self._parse_integer(self.quantity_var.get(), "线圈数量")
- timeout_ms = self._parse_integer(self.timeout_var.get(), "接收等待时间")
- baud_rate = self._parse_integer(self.baud_var.get(), "波特率")
- if not 10 <= timeout_ms <= 10000:
- raise ValueError("接收等待时间必须为10~10000ms")
-
- frame = build_read_coils_request(slave, address, quantity)
- except ValueError as error:
- messagebox.showerror("参数错误", str(error))
- return
-
- self.status_var.set(f"正在执行:{test_name}")
- worker = threading.Thread(
- target=self._run_test,
- args=(
- test_name,
- port_name,
- slave,
- frame,
- gap_us,
- timeout_ms,
- baud_rate,
- self.character_us,
- ),
- daemon=True,
- )
- worker.start()
-
- def _run_test(
- self,
- test_name,
- port_name,
- slave,
- frame,
- gap_us,
- timeout_ms,
- baud_rate,
- character_us,
- ):
- try:
- response, timing = send_request(
- port_name,
- frame,
- gap_us,
- timeout_ms,
- baud_rate,
- character_us,
- )
- result = describe_response(response, slave)
- self.result_queue.put(
- (True, test_name, frame, response, gap_us, timing, result)
- )
- except (OSError, serial.SerialException) as error:
- self.result_queue.put((False, str(error)))
-
- def _poll_result(self):
- try:
- result = self.result_queue.get_nowait()
- except queue.Empty:
- self.root.after(50, self._poll_result)
- return
-
- if result[0]:
- _, name, request, response, gap_us, timing, description = result
- gap_text = "连续发送" if gap_us is None else f"设定{gap_us}us"
- if timing["head_to_head"]:
- costs = timing["write_costs"]
- intervals = timing["head_to_head"]
- silence = timing["estimated_silence"]
- character_us = timing["character_us"]
- gap_text += (
- f"\nwrite+flush耗时{min(costs):.0f}~{max(costs):.0f}us"
- f",主机头到头{min(intervals):.0f}~{max(intervals):.0f}us"
- f",减去{character_us}us后估算线路静默"
- f"{min(silence):.0f}~{max(silence):.0f}us"
- )
- lines = (
- f"[{name}] {gap_text}\n"
- f"TX: {request.hex(' ').upper()}\n"
- f"RX: {response.hex(' ').upper() if response else '<无>'}\n"
- f"结果: {description}\n\n"
- )
- self._append_log(lines)
- self.status_var.set("测试完成")
- else:
- self._append_log(f"串口错误: {result[1]}\n\n")
- self.status_var.set("测试失败")
-
- self.root.after(50, self._poll_result)
-
- def _append_log(self, text):
- self.log.configure(state="normal")
- self.log.insert("end", text)
- self.log.see("end")
- self.log.configure(state="disabled")
-
-
- def self_test():
- """Check calculations without opening a serial port or GUI."""
- request = build_read_coils_request(1, 0, 1)
- expected = bytes.fromhex("01 01 00 00 00 01 FD CA")
- if request != expected:
- raise RuntimeError(
- f"CRC self-test failed: {request.hex(' ').upper()}"
- )
- for baud_rate in BAUD_RATES:
- character_us, t12_us, t15_us, t35_us, invalid_gap_us = (
- calculate_timing(baud_rate)
- )
- if not (t12_us < t15_us < invalid_gap_us < t35_us < NEW_FRAME_GAP_US):
- raise RuntimeError(f"Timing self-test failed at {baud_rate}")
- print(
- f"Baud={baud_rate}, Char={character_us}us, T1.2={t12_us}us, "
- f"T1.5={t15_us}us, T3.5={t35_us}us"
- )
- print("Self-test passed")
- print(f"Request: {request.hex(' ').upper()}")
-
-
- def main():
- parser = argparse.ArgumentParser()
- parser.add_argument("--self-test", action="store_true")
- args = parser.parse_args()
-
- if args.self_test:
- self_test()
- return
-
- root = tk.Tk()
- TimingTester(root)
- root.mainloop()
-
-
- if __name__ == "__main__":
- main()
|