"""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("<>", 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()