您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 
 
 
 
 

492 行
16 KiB

  1. """Modbus RTU T1.5/T3.5 test tool for the STM32 slave project."""
  2. import argparse
  3. import queue
  4. import threading
  5. import time
  6. import tkinter as tk
  7. from tkinter import messagebox, ttk
  8. import serial
  9. from serial.tools import list_ports
  10. BAUD_RATES = (9600, 19200, 115200)
  11. BITS_PER_CHARACTER = 11
  12. NEW_FRAME_GAP_US = 5000
  13. def calculate_timing(baud_rate):
  14. """Return character, T1.2, T1.5 and T3.5 times in microseconds."""
  15. character_us = round(BITS_PER_CHARACTER * 1_000_000 / baud_rate)
  16. t12_us = round(character_us * 1.2)
  17. if baud_rate > 19200:
  18. t15_us = 750
  19. t35_us = 1750
  20. else:
  21. t15_us = round(character_us * 1.5)
  22. t35_us = round(character_us * 3.5)
  23. invalid_gap_us = round((t15_us + t35_us) / 2)
  24. return character_us, t12_us, t15_us, t35_us, invalid_gap_us
  25. def modbus_crc(data):
  26. """Return the Modbus RTU CRC-16 value."""
  27. crc = 0xFFFF
  28. for value in data:
  29. crc ^= value
  30. for _ in range(8):
  31. if crc & 1:
  32. crc = (crc >> 1) ^ 0xA001
  33. else:
  34. crc >>= 1
  35. return crc
  36. def build_read_coils_request(slave, address, quantity):
  37. """Build an 0x01 Read Coils RTU request."""
  38. if not 1 <= slave <= 247:
  39. raise ValueError("从站地址必须为1~247")
  40. if not 0 <= address <= 0xFFFF:
  41. raise ValueError("线圈地址必须为0~65535")
  42. if not 1 <= quantity <= 2000:
  43. raise ValueError("线圈数量必须为1~2000")
  44. if address + quantity > 0x10000:
  45. raise ValueError("地址与数量之和超过65536")
  46. frame = bytearray(
  47. (
  48. slave,
  49. 0x01,
  50. address >> 8,
  51. address & 0xFF,
  52. quantity >> 8,
  53. quantity & 0xFF,
  54. )
  55. )
  56. crc = modbus_crc(frame)
  57. frame.extend((crc & 0xFF, crc >> 8))
  58. return bytes(frame)
  59. def send_request(
  60. port_name, frame, gap_us, timeout_ms, baud_rate, character_us
  61. ):
  62. """Send one RTU request and return received bytes and host timing data."""
  63. write_starts = []
  64. write_costs = []
  65. with serial.Serial(
  66. port=port_name,
  67. baudrate=baud_rate,
  68. bytesize=serial.EIGHTBITS,
  69. parity=serial.PARITY_EVEN,
  70. stopbits=serial.STOPBITS_ONE,
  71. timeout=0,
  72. write_timeout=1,
  73. ) as port:
  74. port.reset_input_buffer()
  75. port.reset_output_buffer()
  76. if gap_us is None:
  77. port.write(frame)
  78. port.flush()
  79. else:
  80. for index, value in enumerate(frame):
  81. write_start = time.perf_counter_ns()
  82. write_starts.append(write_start)
  83. port.write(bytes((value,)))
  84. port.flush()
  85. write_costs.append(
  86. (time.perf_counter_ns() - write_start) / 1000.0
  87. )
  88. if index + 1 < len(frame):
  89. # 下一次write的目标时刻=本次write开始时刻+一个字符时间
  90. # +目标静默时间。这样write、flush和循环开销都会被自动
  91. # 计入并从剩余等待时间中扣除。
  92. next_write_deadline = write_start + (
  93. gap_us + character_us
  94. ) * 1000
  95. while time.perf_counter_ns() < next_write_deadline:
  96. pass
  97. deadline = time.perf_counter() + timeout_ms / 1000.0
  98. response = bytearray()
  99. while time.perf_counter() < deadline:
  100. waiting = port.in_waiting
  101. if waiting:
  102. response.extend(port.read(waiting))
  103. time.sleep(0.001)
  104. waiting = port.in_waiting
  105. if waiting:
  106. response.extend(port.read(waiting))
  107. head_to_head = [
  108. (write_starts[index] - write_starts[index - 1]) / 1000.0
  109. for index in range(1, len(write_starts))
  110. ]
  111. estimated_silence = [
  112. interval - character_us for interval in head_to_head
  113. ]
  114. timing = {
  115. "baud_rate": baud_rate,
  116. "character_us": character_us,
  117. "write_costs": write_costs,
  118. "head_to_head": head_to_head,
  119. "estimated_silence": estimated_silence,
  120. }
  121. return bytes(response), timing
  122. def describe_response(response, slave):
  123. """Perform basic RTU response checks and return a readable result."""
  124. if not response:
  125. return "未收到响应"
  126. if len(response) < 5:
  127. return "响应长度不足5字节"
  128. received_crc = response[-2] | (response[-1] << 8)
  129. calculated_crc = modbus_crc(response[:-2])
  130. if received_crc != calculated_crc:
  131. return "响应CRC错误"
  132. if response[0] != slave:
  133. return "响应从站地址不匹配"
  134. if response[1] == 0x81:
  135. return f"从站返回异常码0x{response[2]:02X}"
  136. if response[1] != 0x01:
  137. return f"响应功能码错误:0x{response[1]:02X}"
  138. return "01响应格式及CRC正确"
  139. class TimingTester:
  140. """Tkinter user interface for the timing tests."""
  141. def __init__(self, root):
  142. self.root = root
  143. self.root.title("Modbus RTU T1.5/T3.5测试工具")
  144. self.root.resizable(False, False)
  145. self.result_queue = queue.Queue()
  146. self.port_var = tk.StringVar()
  147. self.baud_var = tk.StringVar(value="9600")
  148. self.slave_var = tk.StringVar(value="1")
  149. self.address_var = tk.StringVar(value="0")
  150. self.quantity_var = tk.StringVar(value="1")
  151. self.timeout_var = tk.StringVar(value="300")
  152. self.custom_gap_var = tk.StringVar()
  153. self.status_var = tk.StringVar(value="就绪")
  154. self._update_timing_values()
  155. self.custom_gap_var.set(str(self.t12_us))
  156. self._build_ui()
  157. self.refresh_ports()
  158. self.root.after(50, self._poll_result)
  159. def _build_ui(self):
  160. settings = ttk.LabelFrame(self.root, text="通信参数(8E1)")
  161. settings.grid(row=0, column=0, padx=10, pady=8, sticky="ew")
  162. ttk.Label(settings, text="串口").grid(row=0, column=0, padx=5, pady=5)
  163. self.port_box = ttk.Combobox(
  164. settings, textvariable=self.port_var, width=14, state="readonly"
  165. )
  166. self.port_box.grid(row=0, column=1, padx=5, pady=5)
  167. ttk.Button(settings, text="刷新", command=self.refresh_ports).grid(
  168. row=0, column=2, padx=5, pady=5
  169. )
  170. ttk.Label(settings, text="波特率").grid(row=1, column=0, padx=5, pady=5)
  171. self.baud_box = ttk.Combobox(
  172. settings,
  173. textvariable=self.baud_var,
  174. values=[str(value) for value in BAUD_RATES],
  175. width=14,
  176. state="readonly",
  177. )
  178. self.baud_box.grid(row=1, column=1, padx=5, pady=5)
  179. self.baud_box.bind("<<ComboboxSelected>>", self._on_baud_changed)
  180. ttk.Label(settings, text="从站地址").grid(row=2, column=0, padx=5, pady=5)
  181. ttk.Entry(settings, textvariable=self.slave_var, width=16).grid(
  182. row=2, column=1, padx=5, pady=5
  183. )
  184. ttk.Label(settings, text="起始线圈地址").grid(
  185. row=3, column=0, padx=5, pady=5
  186. )
  187. ttk.Entry(settings, textvariable=self.address_var, width=16).grid(
  188. row=3, column=1, padx=5, pady=5
  189. )
  190. ttk.Label(settings, text="线圈数量").grid(row=4, column=0, padx=5, pady=5)
  191. ttk.Entry(settings, textvariable=self.quantity_var, width=16).grid(
  192. row=4, column=1, padx=5, pady=5
  193. )
  194. ttk.Label(settings, text="接收等待/ms").grid(row=5, column=0, padx=5, pady=5)
  195. ttk.Entry(settings, textvariable=self.timeout_var, width=16).grid(
  196. row=5, column=1, padx=5, pady=5
  197. )
  198. timing = ttk.LabelFrame(self.root, text="定时测试")
  199. timing.grid(row=1, column=0, padx=10, pady=2, sticky="ew")
  200. self.timing_info = ttk.Label(timing, text=self._timing_info_text())
  201. self.timing_info.grid(
  202. row=0, column=0, columnspan=3, padx=5, pady=6
  203. )
  204. ttk.Button(
  205. timing,
  206. text="正常连续帧",
  207. command=lambda: self.start_test("正常连续帧", None),
  208. width=20,
  209. ).grid(row=1, column=0, padx=5, pady=5)
  210. self.t12_button = ttk.Button(
  211. timing,
  212. text=f"T1.2({self.t12_us}us)",
  213. command=lambda: self.start_test("T1.2有效帧", self.t12_us),
  214. width=20,
  215. )
  216. self.t12_button.grid(row=1, column=1, padx=5, pady=5)
  217. self.invalid_button = ttk.Button(
  218. timing,
  219. text=f"T1.5~T3.5({self.invalid_gap_us}us)",
  220. command=lambda: self.start_test(
  221. "T1.5至T3.5无效帧", self.invalid_gap_us
  222. ),
  223. width=20,
  224. )
  225. self.invalid_button.grid(row=2, column=0, padx=5, pady=5)
  226. ttk.Button(
  227. timing,
  228. text=f">T3.5({NEW_FRAME_GAP_US}us)",
  229. command=lambda: self.start_test(
  230. "超过T3.5的残帧", NEW_FRAME_GAP_US
  231. ),
  232. width=20,
  233. ).grid(row=2, column=1, padx=5, pady=5)
  234. ttk.Label(timing, text="自定义字节间隔/us").grid(
  235. row=3, column=0, padx=5, pady=5
  236. )
  237. ttk.Entry(timing, textvariable=self.custom_gap_var, width=12).grid(
  238. row=3, column=1, padx=5, pady=5, sticky="w"
  239. )
  240. ttk.Button(timing, text="自定义测试", command=self.start_custom_test).grid(
  241. row=3, column=2, padx=5, pady=5
  242. )
  243. output = ttk.LabelFrame(self.root, text="测试记录")
  244. output.grid(row=2, column=0, padx=10, pady=8, sticky="ew")
  245. self.log = tk.Text(output, width=76, height=16, state="disabled")
  246. self.log.grid(row=0, column=0, padx=5, pady=5)
  247. ttk.Label(self.root, textvariable=self.status_var).grid(
  248. row=3, column=0, padx=10, pady=(0, 8), sticky="w"
  249. )
  250. def _update_timing_values(self):
  251. baud_rate = int(self.baud_var.get())
  252. (
  253. self.character_us,
  254. self.t12_us,
  255. self.t15_us,
  256. self.t35_us,
  257. self.invalid_gap_us,
  258. ) = calculate_timing(baud_rate)
  259. def _timing_info_text(self):
  260. return (
  261. f"1字符={self.character_us}us,T1.5={self.t15_us}us,"
  262. f"T3.5={self.t35_us}us"
  263. )
  264. def _on_baud_changed(self, _event=None):
  265. self._update_timing_values()
  266. self.custom_gap_var.set(str(self.t12_us))
  267. self.timing_info.configure(text=self._timing_info_text())
  268. self.t12_button.configure(text=f"T1.2({self.t12_us}us)")
  269. self.invalid_button.configure(
  270. text=f"T1.5~T3.5({self.invalid_gap_us}us)"
  271. )
  272. def refresh_ports(self):
  273. ports = [item.device for item in list_ports.comports()]
  274. self.port_box["values"] = ports
  275. if ports and self.port_var.get() not in ports:
  276. self.port_var.set(ports[0])
  277. elif not ports:
  278. self.port_var.set("")
  279. @staticmethod
  280. def _parse_integer(value, name):
  281. try:
  282. return int(value.strip(), 0)
  283. except ValueError as error:
  284. raise ValueError(f"{name}不是有效整数") from error
  285. def start_custom_test(self):
  286. try:
  287. gap_us = self._parse_integer(self.custom_gap_var.get(), "自定义间隔")
  288. if gap_us < 0:
  289. raise ValueError("自定义间隔不能小于0")
  290. except ValueError as error:
  291. messagebox.showerror("参数错误", str(error))
  292. return
  293. self.start_test("自定义间隔", gap_us)
  294. def start_test(self, test_name, gap_us):
  295. try:
  296. port_name = self.port_var.get()
  297. if not port_name:
  298. raise ValueError("没有检测到串口,请连接USB转RS-485设备")
  299. slave = self._parse_integer(self.slave_var.get(), "从站地址")
  300. address = self._parse_integer(self.address_var.get(), "线圈地址")
  301. quantity = self._parse_integer(self.quantity_var.get(), "线圈数量")
  302. timeout_ms = self._parse_integer(self.timeout_var.get(), "接收等待时间")
  303. baud_rate = self._parse_integer(self.baud_var.get(), "波特率")
  304. if not 10 <= timeout_ms <= 10000:
  305. raise ValueError("接收等待时间必须为10~10000ms")
  306. frame = build_read_coils_request(slave, address, quantity)
  307. except ValueError as error:
  308. messagebox.showerror("参数错误", str(error))
  309. return
  310. self.status_var.set(f"正在执行:{test_name}")
  311. worker = threading.Thread(
  312. target=self._run_test,
  313. args=(
  314. test_name,
  315. port_name,
  316. slave,
  317. frame,
  318. gap_us,
  319. timeout_ms,
  320. baud_rate,
  321. self.character_us,
  322. ),
  323. daemon=True,
  324. )
  325. worker.start()
  326. def _run_test(
  327. self,
  328. test_name,
  329. port_name,
  330. slave,
  331. frame,
  332. gap_us,
  333. timeout_ms,
  334. baud_rate,
  335. character_us,
  336. ):
  337. try:
  338. response, timing = send_request(
  339. port_name,
  340. frame,
  341. gap_us,
  342. timeout_ms,
  343. baud_rate,
  344. character_us,
  345. )
  346. result = describe_response(response, slave)
  347. self.result_queue.put(
  348. (True, test_name, frame, response, gap_us, timing, result)
  349. )
  350. except (OSError, serial.SerialException) as error:
  351. self.result_queue.put((False, str(error)))
  352. def _poll_result(self):
  353. try:
  354. result = self.result_queue.get_nowait()
  355. except queue.Empty:
  356. self.root.after(50, self._poll_result)
  357. return
  358. if result[0]:
  359. _, name, request, response, gap_us, timing, description = result
  360. gap_text = "连续发送" if gap_us is None else f"设定{gap_us}us"
  361. if timing["head_to_head"]:
  362. costs = timing["write_costs"]
  363. intervals = timing["head_to_head"]
  364. silence = timing["estimated_silence"]
  365. character_us = timing["character_us"]
  366. gap_text += (
  367. f"\nwrite+flush耗时{min(costs):.0f}~{max(costs):.0f}us"
  368. f",主机头到头{min(intervals):.0f}~{max(intervals):.0f}us"
  369. f",减去{character_us}us后估算线路静默"
  370. f"{min(silence):.0f}~{max(silence):.0f}us"
  371. )
  372. lines = (
  373. f"[{name}] {gap_text}\n"
  374. f"TX: {request.hex(' ').upper()}\n"
  375. f"RX: {response.hex(' ').upper() if response else '<无>'}\n"
  376. f"结果: {description}\n\n"
  377. )
  378. self._append_log(lines)
  379. self.status_var.set("测试完成")
  380. else:
  381. self._append_log(f"串口错误: {result[1]}\n\n")
  382. self.status_var.set("测试失败")
  383. self.root.after(50, self._poll_result)
  384. def _append_log(self, text):
  385. self.log.configure(state="normal")
  386. self.log.insert("end", text)
  387. self.log.see("end")
  388. self.log.configure(state="disabled")
  389. def self_test():
  390. """Check calculations without opening a serial port or GUI."""
  391. request = build_read_coils_request(1, 0, 1)
  392. expected = bytes.fromhex("01 01 00 00 00 01 FD CA")
  393. if request != expected:
  394. raise RuntimeError(
  395. f"CRC self-test failed: {request.hex(' ').upper()}"
  396. )
  397. for baud_rate in BAUD_RATES:
  398. character_us, t12_us, t15_us, t35_us, invalid_gap_us = (
  399. calculate_timing(baud_rate)
  400. )
  401. if not (t12_us < t15_us < invalid_gap_us < t35_us < NEW_FRAME_GAP_US):
  402. raise RuntimeError(f"Timing self-test failed at {baud_rate}")
  403. print(
  404. f"Baud={baud_rate}, Char={character_us}us, T1.2={t12_us}us, "
  405. f"T1.5={t15_us}us, T3.5={t35_us}us"
  406. )
  407. print("Self-test passed")
  408. print(f"Request: {request.hex(' ').upper()}")
  409. def main():
  410. parser = argparse.ArgumentParser()
  411. parser.add_argument("--self-test", action="store_true")
  412. args = parser.parse_args()
  413. if args.self_test:
  414. self_test()
  415. return
  416. root = tk.Tk()
  417. TimingTester(root)
  418. root.mainloop()
  419. if __name__ == "__main__":
  420. main()