Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 
 
 
 

1163 строки
38 KiB

  1. """Minimal board test for the 2026 PLSR Modbus product register map.
  2. The script is inert unless --run is supplied. Motion phases additionally
  3. require --allow-motion because they drive the configured pulse output.
  4. """
  5. import argparse
  6. import dataclasses
  7. import re
  8. import subprocess
  9. import sys
  10. import time
  11. import serial
  12. CONFIG_BASE = 0x1000
  13. COMMON_WORDS = 0x14
  14. SEGMENT_1_BASE = 0x1100
  15. SEGMENT_2_BASE = 0x1110
  16. SEGMENT_WORDS = 8
  17. STATUS_BASE = 0x2000
  18. STATUS_WORDS = 7
  19. CONTROL = 0x3000
  20. COMMAND_START = 0x0001
  21. COMMAND_STOP = 0x0002
  22. COMMAND_CLEAR = 0x0004
  23. STATUS_IDLE = 1
  24. STATUS_ACCELERATING = 2
  25. STATUS_RUNNING = 3
  26. STATUS_DECELERATING = 4
  27. STATUS_WAITING = 5
  28. STATUS_COMPLETED = 7
  29. STATUS_STOPPED = 8
  30. STATUS_ERROR = 9
  31. ERROR_NONE = 0
  32. WAIT_TIME = 0
  33. WAIT_SIGNAL = 1
  34. ACT_TIME = 2
  35. EXT_SIGNAL = 3
  36. EXT_OR_COMPLETE = 4
  37. SEND_COMPLETE = 0
  38. SEND_SUBSEQUENT = 1
  39. DEFAULT_STLINK_CLI = r"F:\ST-LINK Utility\ST-LINK_CLI.exe"
  40. GPIOE_CLOCK_BIT = 0x42470610
  41. GPIOI_CLOCK_BIT = 0x42470620
  42. GPIOE_PIN6_OUTPUT_BIT = 0x42420298
  43. GPIOI_PIN8_OUTPUT_BIT = 0x424402A0
  44. GPIOE_PIN6_MODE_LOW_BIT = 0x42420030
  45. GPIOE_PIN6_MODE_HIGH_BIT = 0x42420034
  46. GPIOI_PIN8_MODE_LOW_BIT = 0x42440040
  47. GPIOI_PIN8_MODE_HIGH_BIT = 0x42440044
  48. GPIOE_PIN6_PULL_LOW_BIT = 0x424201B0
  49. GPIOE_PIN6_PULL_HIGH_BIT = 0x424201B4
  50. GPIOI_PIN8_PULL_LOW_BIT = 0x424401C0
  51. GPIOI_PIN8_PULL_HIGH_BIT = 0x424401C4
  52. X4_INPUT_BIT = 0x42408214
  53. X5_INPUT_BIT = 0x42430230
  54. OUTPUT_OFF = 1
  55. OUTPUT_ON = 0
  56. EX_ILLEGAL_FUNCTION = 0x01
  57. EX_ILLEGAL_ADDRESS = 0x02
  58. EX_ILLEGAL_VALUE = 0x03
  59. EX_DEVICE_FAILURE = 0x04
  60. EX_DEVICE_BUSY = 0x06
  61. ACTIVE_STATES = {
  62. STATUS_ACCELERATING,
  63. STATUS_RUNNING,
  64. STATUS_DECELERATING,
  65. STATUS_WAITING,
  66. }
  67. TERMINAL_STATES = {STATUS_COMPLETED, STATUS_STOPPED, STATUS_ERROR}
  68. class TestFailure(RuntimeError):
  69. pass
  70. class ModbusException(RuntimeError):
  71. def __init__(self, function, code):
  72. super().__init__(
  73. "Modbus exception: function=0x%02X code=0x%02X"
  74. % (function, code)
  75. )
  76. self.function = function
  77. self.code = code
  78. def parse_stlink_word(output, address):
  79. pattern = r"(?im)^\s*0x%08x\s*:\s*([0-9a-f]{8})\s*$" % address
  80. match = re.search(pattern, output)
  81. require(match is not None,
  82. "ST-LINK output did not contain address 0x%08X" % address)
  83. return int(match.group(1), 16)
  84. class X45Fixture:
  85. """Drive Y4/Y5 through SWD without adding product test registers."""
  86. def __init__(self, executable, probe_id, timeout):
  87. self.executable = executable
  88. self.probe_id = probe_id
  89. self.timeout = timeout
  90. def _run(self, *arguments):
  91. command_line = [
  92. self.executable,
  93. "-c",
  94. "ID=%d" % self.probe_id,
  95. "SWD",
  96. "HOTPLUG",
  97. ] + list(arguments) + ["-Q", "-NoPrompt"]
  98. try:
  99. result = subprocess.run(
  100. command_line,
  101. stdout=subprocess.PIPE,
  102. stderr=subprocess.STDOUT,
  103. text=True,
  104. errors="replace",
  105. timeout=self.timeout,
  106. check=False,
  107. )
  108. except (OSError, subprocess.SubprocessError) as error:
  109. raise TestFailure("ST-LINK command failed: %s" % error) from error
  110. require(
  111. result.returncode == 0,
  112. "ST-LINK command returned %d:\n%s"
  113. % (result.returncode, result.stdout.strip()),
  114. )
  115. return result.stdout
  116. def prepare(self):
  117. self._run(
  118. "-w32", hex(GPIOE_CLOCK_BIT), "1",
  119. "-w32", hex(GPIOI_CLOCK_BIT), "1",
  120. "-w32", hex(GPIOI_PIN8_OUTPUT_BIT), str(OUTPUT_OFF),
  121. "-w32", hex(GPIOE_PIN6_OUTPUT_BIT), str(OUTPUT_OFF),
  122. "-w32", hex(GPIOI_PIN8_MODE_HIGH_BIT), "0",
  123. "-w32", hex(GPIOI_PIN8_MODE_LOW_BIT), "1",
  124. "-w32", hex(GPIOE_PIN6_MODE_HIGH_BIT), "0",
  125. "-w32", hex(GPIOE_PIN6_MODE_LOW_BIT), "1",
  126. )
  127. time.sleep(0.050)
  128. def drive(self, input_selection, active):
  129. require(input_selection in (0, 1), "input selection must be X4 or X5")
  130. if input_selection == 0:
  131. address = GPIOI_PIN8_OUTPUT_BIT
  132. else:
  133. address = GPIOE_PIN6_OUTPUT_BIT
  134. value = OUTPUT_ON if active else OUTPUT_OFF
  135. self._run("-w32", hex(address), str(value))
  136. time.sleep(0.050)
  137. def read(self, input_selection):
  138. require(input_selection in (0, 1), "input selection must be X4 or X5")
  139. address = X4_INPUT_BIT if input_selection == 0 else X5_INPUT_BIT
  140. output = self._run("-r32", hex(address), "1")
  141. return parse_stlink_word(output, address) & 1
  142. def all_off(self):
  143. self._run(
  144. "-w32", hex(GPIOI_PIN8_OUTPUT_BIT), str(OUTPUT_OFF),
  145. "-w32", hex(GPIOE_PIN6_OUTPUT_BIT), str(OUTPUT_OFF),
  146. )
  147. time.sleep(0.050)
  148. def release(self):
  149. self._run(
  150. "-w32", hex(GPIOI_PIN8_OUTPUT_BIT), str(OUTPUT_OFF),
  151. "-w32", hex(GPIOE_PIN6_OUTPUT_BIT), str(OUTPUT_OFF),
  152. "-w32", hex(GPIOI_PIN8_MODE_LOW_BIT), "0",
  153. "-w32", hex(GPIOI_PIN8_MODE_HIGH_BIT), "0",
  154. "-w32", hex(GPIOI_PIN8_PULL_LOW_BIT), "0",
  155. "-w32", hex(GPIOI_PIN8_PULL_HIGH_BIT), "0",
  156. "-w32", hex(GPIOE_PIN6_MODE_LOW_BIT), "0",
  157. "-w32", hex(GPIOE_PIN6_MODE_HIGH_BIT), "0",
  158. "-w32", hex(GPIOE_PIN6_PULL_LOW_BIT), "0",
  159. "-w32", hex(GPIOE_PIN6_PULL_HIGH_BIT), "0",
  160. )
  161. def require(condition, message):
  162. if not condition:
  163. raise TestFailure(message)
  164. def crc16(data):
  165. value = 0xFFFF
  166. for byte in data:
  167. value ^= byte
  168. for _ in range(8):
  169. if value & 1:
  170. value = (value >> 1) ^ 0xA001
  171. else:
  172. value >>= 1
  173. return value
  174. def append_crc(payload):
  175. checksum = crc16(payload)
  176. return bytes(payload) + bytes((checksum & 0xFF, checksum >> 8))
  177. def split_u32(value):
  178. value &= 0xFFFFFFFF
  179. return [value & 0xFFFF, value >> 16]
  180. def split_i32(value):
  181. require(-(1 << 31) <= value < (1 << 31), "signed value is not int32")
  182. return split_u32(value)
  183. def join_u32(low_word, high_word):
  184. return low_word | (high_word << 16)
  185. def join_i32(low_word, high_word):
  186. value = join_u32(low_word, high_word)
  187. return value - (1 << 32) if value & 0x80000000 else value
  188. def frequency_matches(actual_hz, requested_hz):
  189. tolerance_hz = max(1, requested_hz // 1000)
  190. return abs(actual_hz - requested_hz) <= tolerance_hz
  191. @dataclasses.dataclass
  192. class Status:
  193. position: int
  194. frequency_hz: int
  195. state: int
  196. segment: int
  197. error: int
  198. class RtuClient:
  199. def __init__(self, port, baud, slave, timeout):
  200. self.port_name = port
  201. self.baud = baud
  202. self.slave = slave
  203. self.timeout = timeout
  204. self.character_seconds = 11.0 / baud
  205. if baud > 19200:
  206. self.frame_gap_seconds = 0.00175
  207. else:
  208. self.frame_gap_seconds = 3.5 * self.character_seconds
  209. self.serial_port = None
  210. self.last_request_finished = 0.0
  211. def __enter__(self):
  212. self.serial_port = serial.Serial(
  213. port=self.port_name,
  214. baudrate=self.baud,
  215. bytesize=serial.EIGHTBITS,
  216. parity=serial.PARITY_EVEN,
  217. stopbits=serial.STOPBITS_ONE,
  218. timeout=0,
  219. write_timeout=1,
  220. )
  221. self.serial_port.reset_input_buffer()
  222. self.serial_port.reset_output_buffer()
  223. return self
  224. def __exit__(self, exc_type, exc_value, traceback):
  225. if self.serial_port is not None:
  226. self.serial_port.close()
  227. self.serial_port = None
  228. def _exchange(self, pdu):
  229. request = append_crc(bytes((self.slave,)) + bytes(pdu))
  230. now = time.monotonic()
  231. delay = self.frame_gap_seconds - (now - self.last_request_finished)
  232. if delay > 0:
  233. time.sleep(delay)
  234. self.serial_port.reset_input_buffer()
  235. self.serial_port.write(request)
  236. self.serial_port.flush()
  237. response = bytearray()
  238. deadline = time.monotonic() + self.timeout
  239. expected_length = None
  240. while time.monotonic() < deadline:
  241. waiting = self.serial_port.in_waiting
  242. if waiting:
  243. response.extend(self.serial_port.read(waiting))
  244. if len(response) >= 2 and response[1] == (pdu[0] | 0x80):
  245. expected_length = 5
  246. elif pdu[0] == 0x03 and len(response) >= 3:
  247. expected_length = response[2] + 5
  248. elif pdu[0] in (0x06, 0x10):
  249. expected_length = 8
  250. if expected_length is not None and len(response) >= expected_length:
  251. break
  252. if not waiting:
  253. time.sleep(0.0005)
  254. self.last_request_finished = time.monotonic()
  255. require(response, "no Modbus response to %s" % request.hex(" "))
  256. require(expected_length is not None, "response header is incomplete")
  257. require(len(response) == expected_length,
  258. "incomplete or oversized Modbus response: %s" % response.hex(" "))
  259. require(len(response) >= 5, "short Modbus response: %s" % response.hex(" "))
  260. received_crc = response[-2] | (response[-1] << 8)
  261. require(
  262. received_crc == crc16(response[:-2]),
  263. "bad response CRC: %s" % response.hex(" "),
  264. )
  265. require(response[0] == self.slave, "response slave address mismatch")
  266. requested_function = pdu[0]
  267. if response[1] == (requested_function | 0x80):
  268. require(len(response) == 5, "invalid exception response length")
  269. raise ModbusException(requested_function, response[2])
  270. require(response[1] == requested_function, "response function mismatch")
  271. return bytes(response)
  272. def read_holding(self, address, quantity):
  273. require(1 <= quantity <= 125, "FC03 quantity must be 1..125")
  274. pdu = bytes(
  275. (
  276. 0x03,
  277. address >> 8,
  278. address & 0xFF,
  279. quantity >> 8,
  280. quantity & 0xFF,
  281. )
  282. )
  283. response = self._exchange(pdu)
  284. byte_count = response[2]
  285. require(byte_count == quantity * 2, "FC03 byte count mismatch")
  286. require(len(response) == byte_count + 5, "FC03 response length mismatch")
  287. return [
  288. (response[3 + index * 2] << 8) | response[4 + index * 2]
  289. for index in range(quantity)
  290. ]
  291. def write_single(self, address, value):
  292. value &= 0xFFFF
  293. pdu = bytes(
  294. (
  295. 0x06,
  296. address >> 8,
  297. address & 0xFF,
  298. value >> 8,
  299. value & 0xFF,
  300. )
  301. )
  302. response = self._exchange(pdu)
  303. require(response[:6] == bytes((self.slave,)) + pdu, "FC06 echo mismatch")
  304. def write_multiple(self, address, values):
  305. require(1 <= len(values) <= 123, "FC10 quantity must be 1..123")
  306. data = bytearray()
  307. for value in values:
  308. value &= 0xFFFF
  309. data.extend((value >> 8, value & 0xFF))
  310. quantity = len(values)
  311. pdu = bytes(
  312. (
  313. 0x10,
  314. address >> 8,
  315. address & 0xFF,
  316. quantity >> 8,
  317. quantity & 0xFF,
  318. len(data),
  319. )
  320. ) + bytes(data)
  321. response = self._exchange(pdu)
  322. expected = bytes(
  323. (
  324. self.slave,
  325. 0x10,
  326. address >> 8,
  327. address & 0xFF,
  328. quantity >> 8,
  329. quantity & 0xFF,
  330. )
  331. )
  332. require(response[:6] == expected, "FC10 acknowledgement mismatch")
  333. def read_status(client):
  334. words = client.read_holding(STATUS_BASE, STATUS_WORDS)
  335. return Status(
  336. position=join_i32(words[0], words[1]),
  337. frequency_hz=join_u32(words[2], words[3]),
  338. state=words[4],
  339. segment=words[5],
  340. error=words[6],
  341. )
  342. def wait_for_status(client, predicate, timeout, description):
  343. deadline = time.monotonic() + timeout
  344. last_status = None
  345. while time.monotonic() < deadline:
  346. last_status = read_status(client)
  347. if last_status.state == STATUS_ERROR:
  348. raise TestFailure(
  349. "%s entered ERROR (code=%d)" % (description, last_status.error)
  350. )
  351. if predicate(last_status):
  352. return last_status
  353. time.sleep(0.005)
  354. raise TestFailure("timeout waiting for %s; last=%r" % (description, last_status))
  355. def wait_terminal(client, timeout):
  356. status = wait_for_status(
  357. client,
  358. lambda item: item.state in TERMINAL_STATES,
  359. timeout,
  360. "terminal state",
  361. )
  362. require(status.state != STATUS_ERROR, "motion ended in ERROR %d" % status.error)
  363. return status
  364. def expect_exception(operation, expected_code, label):
  365. try:
  366. operation()
  367. except ModbusException as error:
  368. require(
  369. error.code == expected_code,
  370. "%s expected exception 0x%02X, got 0x%02X"
  371. % (label, expected_code, error.code),
  372. )
  373. return
  374. raise TestFailure("%s did not return a Modbus exception" % label)
  375. def make_common(
  376. position_mode=0,
  377. segment_count=1,
  378. start_segment=1,
  379. send_mode=SEND_COMPLETE,
  380. curve_mode=0,
  381. default_hz=1000,
  382. start_hz=500,
  383. stop_hz=100,
  384. acceleration_ms=0,
  385. deceleration_ms=0,
  386. ):
  387. words = [0] * COMMON_WORDS
  388. words[0x00] = 0
  389. words[0x01] = 0
  390. words[0x02] = 0
  391. words[0x03] = 0
  392. words[0x04] = send_mode
  393. words[0x05] = 0
  394. words[0x06] = 0
  395. words[0x07] = curve_mode
  396. words[0x08] = position_mode
  397. words[0x09] = segment_count
  398. words[0x0A] = start_segment
  399. words[0x0B:0x0D] = split_u32(default_hz)
  400. words[0x0D:0x0F] = split_u32(start_hz)
  401. words[0x0F] = 0
  402. words[0x10:0x12] = split_u32(stop_hz)
  403. words[0x12] = acceleration_ms
  404. words[0x13] = deceleration_ms
  405. return words
  406. def make_segment(
  407. frequency_hz,
  408. pulses,
  409. wait_type=EXT_OR_COMPLETE,
  410. wait_time_ms=0,
  411. act_time_ms=0,
  412. jump_segment=0,
  413. ):
  414. return (
  415. split_u32(frequency_hz)
  416. + split_i32(pulses)
  417. + [wait_type, wait_time_ms, act_time_ms, jump_segment]
  418. )
  419. def configure(client, common, segment_1, segment_2=None):
  420. client.write_multiple(CONFIG_BASE, common)
  421. client.write_multiple(SEGMENT_1_BASE, segment_1)
  422. if segment_2 is not None:
  423. client.write_multiple(SEGMENT_2_BASE, segment_2)
  424. def restore_default_configuration(client):
  425. common = make_common(
  426. default_hz=1000,
  427. start_hz=100,
  428. stop_hz=100,
  429. acceleration_ms=100,
  430. deceleration_ms=100,
  431. )
  432. common[0x05] = 10
  433. client.write_multiple(CONFIG_BASE, common)
  434. for index in range(10):
  435. base = SEGMENT_1_BASE + index * 0x10
  436. pulses = 1000 if index == 0 else 0
  437. client.write_multiple(base, make_segment(1000, pulses))
  438. def command(client, value):
  439. client.write_single(CONTROL, value)
  440. def clear_position(client):
  441. command(client, COMMAND_CLEAR)
  442. status = read_status(client)
  443. require(status.state == STATUS_IDLE, "CLEAR did not enter IDLE")
  444. require(status.position == 0, "CLEAR did not zero logical position")
  445. def run_case(name, function):
  446. started = time.monotonic()
  447. print("RUN %s" % name)
  448. function()
  449. print("PASS %s (%.3fs)" % (name, time.monotonic() - started))
  450. def test_fixed_map_and_exceptions(client):
  451. common = client.read_holding(CONFIG_BASE, COMMON_WORDS)
  452. segment = client.read_holding(SEGMENT_1_BASE, SEGMENT_WORDS)
  453. status = read_status(client)
  454. control = client.read_holding(CONTROL, 1)
  455. require(len(common) == COMMON_WORDS, "common map read failed")
  456. require(len(segment) == SEGMENT_WORDS, "segment map read failed")
  457. require(0 <= status.state <= STATUS_ERROR, "status enum is out of range")
  458. require(control == [0], "control register must read as zero")
  459. require(client.read_holding(0x100F, 1) == [0], "reserved word is not zero")
  460. require(client.read_holding(0x1108, 1) == [0], "segment padding is not zero")
  461. expect_exception(
  462. lambda: client.read_holding(0x0000, 1),
  463. EX_ILLEGAL_ADDRESS,
  464. "FC03 address 0",
  465. )
  466. expect_exception(
  467. lambda: client._exchange(bytes((0x01, 0x00, 0x00, 0x00, 0x01))),
  468. EX_ILLEGAL_FUNCTION,
  469. "FC01 unsupported function",
  470. )
  471. expect_exception(
  472. lambda: client.read_holding(0x1197, 2),
  473. EX_ILLEGAL_ADDRESS,
  474. "range crossing 0x1197",
  475. )
  476. expect_exception(
  477. lambda: client.write_single(0x100F, 1),
  478. EX_ILLEGAL_VALUE,
  479. "nonzero reserved write",
  480. )
  481. expect_exception(
  482. lambda: client.write_single(0x100B, 1000),
  483. EX_ILLEGAL_ADDRESS,
  484. "single half of a DWORD",
  485. )
  486. expect_exception(
  487. lambda: client.write_single(STATUS_BASE, 0),
  488. EX_ILLEGAL_ADDRESS,
  489. "status write",
  490. )
  491. expect_exception(
  492. lambda: client.write_single(CONTROL, 3),
  493. EX_ILLEGAL_VALUE,
  494. "combined control command",
  495. )
  496. def test_positive_negative_and_absolute_zero(client):
  497. clear_position(client)
  498. common = make_common(position_mode=0, start_hz=500)
  499. configure(client, common, make_segment(500, 25))
  500. command(client, COMMAND_START)
  501. status = wait_terminal(client, 3.0)
  502. require(status.state == STATUS_COMPLETED, "positive move did not complete")
  503. require(status.position == 25, "positive accumulation expected 25")
  504. configure(client, common, make_segment(500, -10))
  505. command(client, COMMAND_START)
  506. status = wait_terminal(client, 3.0)
  507. require(status.state == STATUS_COMPLETED, "negative move did not complete")
  508. require(status.position == 15, "negative accumulation expected 15")
  509. absolute = make_common(position_mode=1, start_hz=500)
  510. configure(client, absolute, make_segment(500, 15))
  511. before = read_status(client)
  512. command(client, COMMAND_START)
  513. status = wait_terminal(client, 1.0)
  514. require(status.state == STATUS_COMPLETED, "absolute zero move did not complete")
  515. require(status.position == before.position, "absolute zero changed position")
  516. def test_all_outputs_at_100khz(client):
  517. for output in range(4):
  518. clear_position(client)
  519. common = make_common(
  520. default_hz=100000,
  521. start_hz=100000,
  522. stop_hz=100000,
  523. )
  524. common[0x00] = output
  525. common[0x01] = output
  526. configure(client, common, make_segment(100000, 1000))
  527. command(client, COMMAND_START)
  528. status = wait_terminal(client, 2.0)
  529. require(status.state == STATUS_COMPLETED,
  530. "output %d did not complete" % output)
  531. require(status.position == 1000,
  532. "output %d count expected 1000, got %d"
  533. % (output, status.position))
  534. def test_short_final_profiles(client):
  535. for curve_mode in range(3):
  536. clear_position(client)
  537. common = make_common(
  538. curve_mode=curve_mode,
  539. default_hz=100000,
  540. start_hz=100000,
  541. stop_hz=100,
  542. deceleration_ms=1000,
  543. )
  544. configure(client, common, make_segment(100000, 10))
  545. command(client, COMMAND_START)
  546. status = wait_terminal(client, 2.0)
  547. require(status.state == STATUS_COMPLETED,
  548. "short curve %d did not complete" % curve_mode)
  549. require(status.error == ERROR_NONE,
  550. "short curve %d reported error %d"
  551. % (curve_mode, status.error))
  552. require(status.position == 10,
  553. "short curve %d count expected 10, got %d"
  554. % (curve_mode, status.position))
  555. def test_wait_time(client):
  556. clear_position(client)
  557. configure(
  558. client,
  559. make_common(start_hz=500),
  560. make_segment(500, 10, wait_type=WAIT_TIME, wait_time_ms=120),
  561. )
  562. command(client, COMMAND_START)
  563. waiting = wait_for_status(
  564. client,
  565. lambda item: item.state == STATUS_WAITING,
  566. 2.0,
  567. "WAIT_TIME state",
  568. )
  569. waiting_at = time.monotonic()
  570. require(waiting.position == 10, "WAIT_TIME position expected 10")
  571. status = wait_terminal(client, 2.0)
  572. observed_wait = time.monotonic() - waiting_at
  573. require(status.state == STATUS_COMPLETED, "WAIT_TIME did not complete")
  574. require(observed_wait >= 0.050, "WAIT_TIME completed implausibly early")
  575. def test_act_time(client):
  576. clear_position(client)
  577. configure(
  578. client,
  579. make_common(start_hz=1000),
  580. make_segment(1000, 2000, wait_type=ACT_TIME, act_time_ms=80),
  581. )
  582. command(client, COMMAND_START)
  583. status = wait_terminal(client, 3.0)
  584. require(status.state == STATUS_COMPLETED, "ACT_TIME did not complete")
  585. require(0 < status.position < 2000, "ACT_TIME did not cut the segment")
  586. def test_dynamic_frequency_and_repeated_stop(client):
  587. clear_position(client)
  588. configure(
  589. client,
  590. make_common(start_hz=500, deceleration_ms=150),
  591. make_segment(500, 3000),
  592. )
  593. command(client, COMMAND_START)
  594. wait_for_status(
  595. client,
  596. lambda item: item.segment == 1 and item.frequency_hz == 500,
  597. 2.0,
  598. "initial frequency",
  599. )
  600. client.write_multiple(SEGMENT_1_BASE, split_u32(1200))
  601. dynamic = wait_for_status(
  602. client,
  603. lambda item: item.segment == 1
  604. and frequency_matches(item.frequency_hz, 1200),
  605. 2.0,
  606. "dynamic frequency",
  607. )
  608. print("INFO dynamic frequency requested=1200 actual=%d" %
  609. dynamic.frequency_hz)
  610. expect_exception(
  611. lambda: client.write_single(CONFIG_BASE, 1),
  612. EX_DEVICE_BUSY,
  613. "pulse output write while busy",
  614. )
  615. command(client, COMMAND_STOP)
  616. command(client, COMMAND_STOP)
  617. status = wait_terminal(client, 3.0)
  618. require(status.state == STATUS_STOPPED, "repeated STOP did not stop")
  619. require(status.position < 3000, "STOP did not cut the active move")
  620. def test_future_segment_frequency_applies_on_arrival(client):
  621. clear_position(client)
  622. common = make_common(segment_count=2, start_hz=400)
  623. segment_1 = make_segment(400, 200)
  624. segment_2 = make_segment(700, 200)
  625. configure(client, common, segment_1, segment_2)
  626. command(client, COMMAND_START)
  627. wait_for_status(
  628. client,
  629. lambda item: item.segment == 1 and item.frequency_hz == 400,
  630. 2.0,
  631. "segment 1",
  632. )
  633. client.write_multiple(SEGMENT_2_BASE, split_u32(900))
  634. status = read_status(client)
  635. require(status.segment == 1, "future write changed the current segment")
  636. require(status.frequency_hz == 400, "future write changed current frequency")
  637. wait_for_status(
  638. client,
  639. lambda item: item.segment == 2
  640. and frequency_matches(item.frequency_hz, 900),
  641. 3.0,
  642. "updated segment 2 frequency",
  643. )
  644. status = wait_terminal(client, 3.0)
  645. require(status.state == STATUS_COMPLETED, "two-segment move did not complete")
  646. def test_subsequent_future_frequency_rebuilds_handoff(client):
  647. clear_position(client)
  648. common = make_common(
  649. segment_count=2,
  650. send_mode=SEND_SUBSEQUENT,
  651. start_hz=400,
  652. )
  653. segment_1 = make_segment(400, 200)
  654. segment_2 = make_segment(700, 200)
  655. configure(client, common, segment_1, segment_2)
  656. command(client, COMMAND_START)
  657. wait_for_status(
  658. client,
  659. lambda item: item.segment == 1
  660. and frequency_matches(item.frequency_hz, 400),
  661. 2.0,
  662. "subsequent segment 1",
  663. )
  664. client.write_multiple(SEGMENT_2_BASE, split_u32(900))
  665. status = read_status(client)
  666. require(status.segment == 1, "future write changed the current segment")
  667. require(
  668. frequency_matches(status.frequency_hz, 400),
  669. "future write changed current frequency",
  670. )
  671. wait_for_status(
  672. client,
  673. lambda item: item.segment == 2
  674. and frequency_matches(item.frequency_hz, 900),
  675. 3.0,
  676. "rebuilt subsequent handoff frequency",
  677. )
  678. status = wait_terminal(client, 3.0)
  679. require(status.state == STATUS_COMPLETED,
  680. "subsequent two-segment move did not complete")
  681. require(status.position == 400,
  682. "subsequent two-segment position expected 400")
  683. def require_fixture_level(fixture, input_selection, expected, label):
  684. actual = fixture.read(input_selection)
  685. require(
  686. actual == expected,
  687. "%s expected X%d=%d, got %d"
  688. % (label, input_selection + 4, expected, actual),
  689. )
  690. def test_x45_electrical_scan(fixture):
  691. fixture.all_off()
  692. require_fixture_level(fixture, 0, 0, "both outputs off")
  693. require_fixture_level(fixture, 1, 0, "both outputs off")
  694. for input_selection in range(2):
  695. other = 1 - input_selection
  696. fixture.drive(input_selection, True)
  697. require_fixture_level(fixture, input_selection, 1, "output on")
  698. require_fixture_level(fixture, other, 0, "cross-channel isolation")
  699. fixture.drive(input_selection, False)
  700. require_fixture_level(fixture, input_selection, 0, "output off")
  701. def test_wait_signal_input(client, fixture, input_selection):
  702. fixture.drive(input_selection, False)
  703. clear_position(client)
  704. common = make_common(
  705. default_hz=1000,
  706. start_hz=1000,
  707. stop_hz=1000,
  708. )
  709. common[0x02] = input_selection
  710. configure(client, common, make_segment(1000, 50, wait_type=WAIT_SIGNAL))
  711. command(client, COMMAND_START)
  712. waiting = wait_for_status(
  713. client,
  714. lambda item: item.state == STATUS_WAITING,
  715. 2.0,
  716. "X%d WAIT_SIGNAL" % (input_selection + 4),
  717. )
  718. require(waiting.position == 50, "WAIT_SIGNAL pulse count expected 50")
  719. time.sleep(0.050)
  720. require(read_status(client).state == STATUS_WAITING,
  721. "WAIT_SIGNAL advanced while input was off")
  722. fixture.drive(input_selection, True)
  723. status = wait_terminal(client, 2.0)
  724. require(status.state == STATUS_COMPLETED, "WAIT_SIGNAL did not complete")
  725. require(status.position == 50, "WAIT_SIGNAL changed completed position")
  726. fixture.drive(input_selection, False)
  727. def test_ext_signal_input(client, fixture, input_selection, wait_type):
  728. fixture.drive(input_selection, False)
  729. clear_position(client)
  730. common = make_common(
  731. default_hz=1000,
  732. start_hz=1000,
  733. stop_hz=1000,
  734. )
  735. common[0x03] = input_selection
  736. configure(client, common, make_segment(1000, 5000, wait_type=wait_type))
  737. command(client, COMMAND_START)
  738. wait_for_status(
  739. client,
  740. lambda item: item.state in ACTIVE_STATES and item.position >= 10,
  741. 2.0,
  742. "X%d external-signal active move" % (input_selection + 4),
  743. )
  744. fixture.drive(input_selection, True)
  745. status = wait_terminal(client, 2.0)
  746. require(status.state == STATUS_COMPLETED,
  747. "external-signal move did not complete")
  748. require(0 < status.position < 5000,
  749. "external signal did not cut the active segment")
  750. fixture.drive(input_selection, False)
  751. def test_ext_or_complete_natural(client, fixture, input_selection):
  752. fixture.drive(input_selection, False)
  753. clear_position(client)
  754. common = make_common(
  755. default_hz=1000,
  756. start_hz=1000,
  757. stop_hz=1000,
  758. )
  759. common[0x03] = input_selection
  760. configure(
  761. client,
  762. common,
  763. make_segment(1000, 50, wait_type=EXT_OR_COMPLETE),
  764. )
  765. command(client, COMMAND_START)
  766. status = wait_terminal(client, 2.0)
  767. require(status.state == STATUS_COMPLETED,
  768. "EXT_OR_COMPLETE natural branch did not complete")
  769. require(status.position == 50,
  770. "EXT_OR_COMPLETE natural branch expected 50 pulses")
  771. def run_x45(client, fixture, keep_config):
  772. snapshot = (
  773. client.read_holding(CONFIG_BASE, COMMON_WORDS),
  774. client.read_holding(SEGMENT_1_BASE, SEGMENT_WORDS),
  775. client.read_holding(SEGMENT_2_BASE, SEGMENT_WORDS),
  776. )
  777. try:
  778. fixture.prepare()
  779. run_case("x45_electrical_scan",
  780. lambda: test_x45_electrical_scan(fixture))
  781. for input_selection in range(2):
  782. name = "X%d" % (input_selection + 4)
  783. run_case(
  784. "%s_wait_signal" % name,
  785. lambda selection=input_selection:
  786. test_wait_signal_input(client, fixture, selection),
  787. )
  788. run_case(
  789. "%s_ext_signal" % name,
  790. lambda selection=input_selection:
  791. test_ext_signal_input(
  792. client, fixture, selection, EXT_SIGNAL),
  793. )
  794. run_case(
  795. "%s_ext_or_complete_natural" % name,
  796. lambda selection=input_selection:
  797. test_ext_or_complete_natural(client, fixture, selection),
  798. )
  799. run_case(
  800. "%s_ext_or_complete_trigger" % name,
  801. lambda selection=input_selection:
  802. test_ext_signal_input(
  803. client, fixture, selection, EXT_OR_COMPLETE),
  804. )
  805. finally:
  806. try:
  807. fixture.all_off()
  808. finally:
  809. try:
  810. safe_stop(client)
  811. if not keep_config:
  812. restore_configuration(client, snapshot)
  813. finally:
  814. fixture.release()
  815. def safe_stop(client):
  816. try:
  817. status = read_status(client)
  818. if status.state in ACTIVE_STATES:
  819. command(client, COMMAND_STOP)
  820. wait_terminal(client, 3.0)
  821. except (ModbusException, TestFailure, serial.SerialException):
  822. pass
  823. def restore_configuration(client, snapshot):
  824. safe_stop(client)
  825. command(client, COMMAND_CLEAR)
  826. client.write_multiple(CONFIG_BASE, snapshot[0])
  827. client.write_multiple(SEGMENT_1_BASE, snapshot[1])
  828. client.write_multiple(SEGMENT_2_BASE, snapshot[2])
  829. def run_smoke(client):
  830. run_case("fixed_map_and_exceptions", lambda: test_fixed_map_and_exceptions(client))
  831. def run_all(client, keep_config):
  832. snapshot = (
  833. client.read_holding(CONFIG_BASE, COMMON_WORDS),
  834. client.read_holding(SEGMENT_1_BASE, SEGMENT_WORDS),
  835. client.read_holding(SEGMENT_2_BASE, SEGMENT_WORDS),
  836. )
  837. try:
  838. run_smoke(client)
  839. run_case(
  840. "positive_negative_absolute_zero",
  841. lambda: test_positive_negative_and_absolute_zero(client),
  842. )
  843. run_case("all_outputs_100khz",
  844. lambda: test_all_outputs_at_100khz(client))
  845. run_case("short_final_profiles",
  846. lambda: test_short_final_profiles(client))
  847. run_case("wait_time", lambda: test_wait_time(client))
  848. run_case("act_time", lambda: test_act_time(client))
  849. run_case(
  850. "dynamic_frequency_repeated_stop",
  851. lambda: test_dynamic_frequency_and_repeated_stop(client),
  852. )
  853. run_case(
  854. "future_segment_frequency_on_arrival",
  855. lambda: test_future_segment_frequency_applies_on_arrival(client),
  856. )
  857. run_case(
  858. "subsequent_future_frequency_handoff",
  859. lambda: test_subsequent_future_frequency_rebuilds_handoff(client),
  860. )
  861. finally:
  862. if keep_config:
  863. safe_stop(client)
  864. else:
  865. restore_configuration(client, snapshot)
  866. def persistence_prepare(client):
  867. clear_position(client)
  868. configure(
  869. client,
  870. make_common(start_hz=500),
  871. make_segment(500, 7),
  872. )
  873. command(client, COMMAND_START)
  874. status = wait_terminal(client, 3.0)
  875. require(status.state == STATUS_COMPLETED, "persistence move did not complete")
  876. require(status.position == 7, "persistence position expected 7")
  877. time.sleep(1.2)
  878. require(read_status(client).position == 7, "position changed before reset")
  879. print("PREPARED: release COM, reset/power-cycle the MCU, then run verify phase")
  880. def persistence_verify(client, cleanup):
  881. status = read_status(client)
  882. require(status.state == STATUS_IDLE, "post-reset state must be IDLE")
  883. require(status.position == 7, "post-reset position expected 7")
  884. segment = client.read_holding(SEGMENT_1_BASE, SEGMENT_WORDS)
  885. require(join_u32(segment[0], segment[1]) == 500, "frequency was not restored")
  886. require(join_i32(segment[2], segment[3]) == 7, "pulse target was not restored")
  887. if cleanup:
  888. command(client, COMMAND_CLEAR)
  889. require(read_status(client).position == 0, "cleanup CLEAR failed")
  890. restore_default_configuration(client)
  891. time.sleep(1.2)
  892. common = client.read_holding(CONFIG_BASE, COMMON_WORDS)
  893. segment = client.read_holding(SEGMENT_1_BASE, SEGMENT_WORDS)
  894. require(join_u32(common[0x0B], common[0x0C]) == 1000,
  895. "cleanup default speed was not restored")
  896. require(join_u32(segment[0], segment[1]) == 1000,
  897. "cleanup segment frequency was not restored")
  898. require(join_i32(segment[2], segment[3]) == 1000,
  899. "cleanup segment pulses were not restored")
  900. def defaults_verify(client):
  901. status = read_status(client)
  902. require(status.state == STATUS_IDLE, "default state must be IDLE")
  903. require(status.position == 0, "default position must be zero")
  904. common = client.read_holding(CONFIG_BASE, COMMON_WORDS)
  905. require(common[0x05] == 10, "default direction delay expected 10 ms")
  906. require(join_u32(common[0x0B], common[0x0C]) == 1000,
  907. "default speed expected 1000 Hz")
  908. require(join_u32(common[0x0D], common[0x0E]) == 100,
  909. "start speed expected 100 Hz")
  910. require(join_u32(common[0x10], common[0x11]) == 100,
  911. "stop speed expected 100 Hz")
  912. for index in range(10):
  913. base = SEGMENT_1_BASE + index * 0x10
  914. segment = client.read_holding(base, SEGMENT_WORDS)
  915. expected_pulses = 1000 if index == 0 else 0
  916. require(join_u32(segment[0], segment[1]) == 1000,
  917. "default segment %d frequency mismatch" % (index + 1))
  918. require(join_i32(segment[2], segment[3]) == expected_pulses,
  919. "default segment %d pulse count mismatch" % (index + 1))
  920. def self_test():
  921. payload = bytes.fromhex("01 01 00 00 00 01")
  922. require(crc16(payload) == 0xCAFD, "CRC reference vector failed")
  923. require(append_crc(payload) == bytes.fromhex("01 01 00 00 00 01 FD CA"),
  924. "wire CRC order failed")
  925. for value in (0, 1, 0x7FFFFFFF, -1, -123456, -0x80000000):
  926. words = split_i32(value)
  927. require(join_i32(words[0], words[1]) == value, "int32 word round trip failed")
  928. require(frequency_matches(1199, 1200), "frequency tolerance rejected 1 Hz")
  929. require(not frequency_matches(1198, 1200),
  930. "frequency tolerance accepted excessive error")
  931. sample = "\n0x42408214 : 00000001 \n"
  932. require(parse_stlink_word(sample, X4_INPUT_BIT) == 1,
  933. "ST-LINK read parser failed")
  934. print("Self-test passed; no serial port was opened")
  935. def print_preflight(args):
  936. print("No serial port opened. Board-test preconditions:")
  937. print(" - Firmware containing the PLSR 0x1000/0x2000/0x3000 map is flashed.")
  938. print(" - RS-485 is %d baud, 8E1, slave %d on %s." %
  939. (args.baud, args.slave, args.port))
  940. print(" - Y0 pulse and Y12 direction outputs are safe to toggle.")
  941. print(" - No other program has the COM port open.")
  942. print("Run smoke only:")
  943. print(" py -B HostComputer\\plsr_modbus_product_test.py --run --phase smoke")
  944. print("Run motion matrix:")
  945. print(" py -B HostComputer\\plsr_modbus_product_test.py --run --allow-motion")
  946. print("Run X4/X5 loopback tests:")
  947. print(" py -B HostComputer\\plsr_modbus_product_test.py --run --phase x45 --allow-motion")
  948. print("Run offline checks:")
  949. print(" py -B HostComputer\\plsr_modbus_product_test.py --self-test")
  950. def parse_arguments():
  951. parser = argparse.ArgumentParser(description=__doc__)
  952. parser.add_argument("--port", default="COM5")
  953. parser.add_argument("--baud", type=int, default=9600)
  954. parser.add_argument("--slave", type=int, default=1)
  955. parser.add_argument("--timeout", type=float, default=1.5)
  956. parser.add_argument("--stlink-cli", default=DEFAULT_STLINK_CLI)
  957. parser.add_argument("--stlink-id", type=int, default=0)
  958. parser.add_argument("--stlink-timeout", type=float, default=20.0)
  959. parser.add_argument(
  960. "--phase",
  961. choices=("smoke", "all", "x45", "persistence-prepare",
  962. "persistence-verify", "defaults-verify"),
  963. default="all",
  964. )
  965. parser.add_argument("--run", action="store_true",
  966. help="open the serial port and execute the selected phase")
  967. parser.add_argument("--allow-motion", action="store_true",
  968. help="confirm that pulse and direction outputs may toggle")
  969. parser.add_argument("--keep-config", action="store_true",
  970. help="do not restore the three modified config blocks")
  971. parser.add_argument("--cleanup", action="store_true",
  972. help="CLEAR position after persistence verification")
  973. parser.add_argument("--self-test", action="store_true",
  974. help="run offline CRC/word tests without opening a port")
  975. return parser.parse_args()
  976. def main():
  977. args = parse_arguments()
  978. require(1 <= args.slave <= 247, "slave must be 1..247")
  979. require(args.baud > 0, "baud must be positive")
  980. require(args.timeout > 0, "timeout must be positive")
  981. require(args.stlink_id >= 0, "ST-LINK ID must not be negative")
  982. require(args.stlink_timeout > 0, "ST-LINK timeout must be positive")
  983. if args.self_test:
  984. self_test()
  985. return 0
  986. if not args.run:
  987. print_preflight(args)
  988. return 0
  989. motion_phase = args.phase in ("all", "x45", "persistence-prepare")
  990. if motion_phase and not args.allow_motion:
  991. raise TestFailure("motion phase requires --allow-motion")
  992. print("Opening %s at %d 8E1, slave %d" %
  993. (args.port, args.baud, args.slave))
  994. with RtuClient(args.port, args.baud, args.slave, args.timeout) as client:
  995. if args.phase == "smoke":
  996. run_smoke(client)
  997. elif args.phase == "all":
  998. run_all(client, args.keep_config)
  999. elif args.phase == "x45":
  1000. fixture = X45Fixture(
  1001. args.stlink_cli,
  1002. args.stlink_id,
  1003. args.stlink_timeout,
  1004. )
  1005. run_x45(client, fixture, args.keep_config)
  1006. elif args.phase == "persistence-prepare":
  1007. persistence_prepare(client)
  1008. elif args.phase == "persistence-verify":
  1009. persistence_verify(client, args.cleanup)
  1010. else:
  1011. defaults_verify(client)
  1012. print("PASS phase=%s" % args.phase)
  1013. return 0
  1014. if __name__ == "__main__":
  1015. try:
  1016. sys.exit(main())
  1017. except (TestFailure, ModbusException, serial.SerialException) as error:
  1018. print("FAIL: %s" % error, file=sys.stderr)
  1019. sys.exit(1)