Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 
 
 
 
 

742 řádky
33 KiB

  1. #!/usr/bin/env python3
  2. """PLSR Modbus RTU test client.
  3. The ``spec`` map follows ``document/参数表-2026.xlsx``. ``legacy`` keeps
  4. compatibility with the first single-segment firmware image in this project.
  5. """
  6. from __future__ import annotations
  7. import argparse
  8. import sys
  9. import time
  10. from dataclasses import dataclass, field
  11. from typing import Optional, Sequence
  12. try:
  13. import serial
  14. from serial.tools import list_ports
  15. except ImportError:
  16. serial = None
  17. list_ports = None
  18. SLAVE_ADDRESS = 1
  19. DEFAULT_BAUD_RATE = 115200
  20. MAP_SPEC = "spec"
  21. MAP_LEGACY = "legacy"
  22. # Requirement-map addresses. These are holding-register offsets, not 4xxxx
  23. # display addresses. A 32-bit value uses high word first, low word second.
  24. REG_CFG_PULSE_OUTPUT = 0x1000
  25. REG_CFG_DIRECTION_OUTPUT = 0x1001
  26. REG_CFG_WAIT_INPUT = 0x1002
  27. REG_CFG_EXT_INPUT = 0x1003
  28. REG_CFG_SEND_MODE = 0x1004
  29. REG_CFG_DIRECTION_DELAY_MS = 0x1005
  30. REG_CFG_DIRECTION_LOGIC = 0x1006
  31. REG_CFG_ACCEL_MODE = 0x1007
  32. REG_CFG_RUN_MODE = 0x1008
  33. REG_CFG_SEGMENT_COUNT = 0x1009
  34. REG_CFG_START_SEGMENT = 0x100A
  35. REG_CFG_DEFAULT_SPEED = 0x100B
  36. REG_CFG_START_SPEED = 0x100D
  37. REG_CFG_END_SPEED = 0x1010
  38. REG_CFG_ACCEL_TIME_MS = 0x1012
  39. REG_CFG_DECEL_TIME_MS = 0x1013
  40. REG_SEGMENT_BASE = 0x1100
  41. REG_SEGMENT_STRIDE = 0x10
  42. REG_MONITOR_TOTAL_PULSES = 0x2000
  43. REG_MONITOR_FREQUENCY = 0x2002
  44. REG_MONITOR_STATE = 0x2004
  45. REG_MONITOR_SEGMENT = 0x2005
  46. REG_MONITOR_ERROR = 0x2006
  47. REG_MONITOR_STOP_REASON = 0x2007
  48. REG_CONTROL = 0x3000
  49. # Legacy zero-based map used by the current minimal firmware image.
  50. REG_STATE = 0
  51. REG_FLAGS = 1
  52. REG_CURRENT_FREQUENCY = 3
  53. REG_STOP_REASON = 5
  54. REG_LAST_ERROR = 6
  55. REG_TOTAL_PULSES_HIGH = 10
  56. REG_TOTAL_PULSES_LOW = 11
  57. REG_COMMAND = 100
  58. REG_PULSES_HIGH = 102
  59. REG_PULSES_LOW = 103
  60. REG_FREQUENCY = 104
  61. REG_DIRECTION = 105
  62. REG_PULSE_OUTPUT = 106
  63. REG_DIRECTION_OUTPUT = 107
  64. COMMAND_START = 1
  65. COMMAND_STOP = 2
  66. COMMAND_RESET = 3
  67. COMMAND_CLEAR_COUNT = 4
  68. COMMAND_IMMEDIATE_STOP = 5
  69. COMMAND_SAVE_CONFIG = 6
  70. COMMAND_RESTART = 7
  71. CONTROL_START = 1 << 0
  72. CONTROL_STOP = 1 << 1
  73. CONTROL_CLEAR_COUNT = 1 << 2
  74. CONTROL_RESTART = 1 << 3
  75. CONTROL_RESET = 1 << 4
  76. CONTROL_IMMEDIATE_STOP = 1 << 5
  77. STATE_NAMES = {
  78. 0: "Idle",
  79. 1: "Armed",
  80. 2: "Busy",
  81. 3: "Wait",
  82. 4: "Decelerating",
  83. 5: "Done",
  84. 6: "Stopped",
  85. 7: "Error",
  86. }
  87. ERROR_NAMES = {
  88. 0: "OK",
  89. 1: "Invalid argument",
  90. 2: "Busy",
  91. 3: "Resource",
  92. 4: "State",
  93. 5: "Wait timeout",
  94. 6: "Path range",
  95. 7: "Loop limit",
  96. 8: "External stop",
  97. 9: "Config CRC",
  98. 10: "Communication",
  99. 11: "Timer",
  100. }
  101. WAIT_CONDITION_NAMES = {
  102. 0: "WAIT time",
  103. 1: "WAIT signal",
  104. 2: "ACT time",
  105. 3: "EXT signal",
  106. 4: "EXT signal or pulse done",
  107. }
  108. MANDATORY_TEST_ITEMS = (
  109. ("M01", "Y0..Y3 pulse output selection", "automatic register round-trip + oscilloscope"),
  110. ("M02", "Y12..Y15 direction output selection and logic", "automatic register round-trip + level observation"),
  111. ("M03", "positive/negative signed pulse count", "automatic motion/count + direction observation"),
  112. ("M04", "1..100000 Hz and online frequency update", "register boundary + oscilloscope/frequency counter"),
  113. ("M05", "complete/follow-up send mode", "state transition observation"),
  114. ("M06", "relative/absolute mode including equal-position no-op", "motion/count observation"),
  115. ("M07", "linear/S-curve/sine acceleration", "configuration round-trip + oscilloscope capture"),
  116. ("M08", "start/default/end speed and acceleration/deceleration time", "configuration round-trip + waveform timing"),
  117. ("M09", "1..10 segments and selectable start segment", "10-segment round-trip + execution sequence"),
  118. ("M10", "WAIT time and WAIT signal", "timing + manual X4/X5 trigger"),
  119. ("M11", "ACT time and EXT signal", "timing + manual X4/X5 trigger"),
  120. ("M12", "EXT signal or pulse completion", "manual EXT trigger and natural completion branches"),
  121. ("M13", "jump=0/default next/end and explicit jump", "current-segment monitor sequence"),
  122. ("M14", "cumulative signed count and communication clear", "automatic monitor/control check"),
  123. ("M15", "stop, state, current segment and error diagnostics", "automatic state/error monitor check"),
  124. ("M16", "power-loss save/load and power-up parameter restore", "manual power-cycle and readback"),
  125. )
  126. def mandatory_test_checklist() -> str:
  127. return "\n".join(f"{code} {name} [{method}]" for code, name, method in MANDATORY_TEST_ITEMS)
  128. class PlsrError(RuntimeError):
  129. pass
  130. class CommunicationError(PlsrError):
  131. pass
  132. class ModbusException(PlsrError):
  133. def __init__(self, function: int, code: int) -> None:
  134. super().__init__(f"Modbus exception: function=0x{function:02X}, code=0x{code:02X}")
  135. self.function = function
  136. self.code = code
  137. def crc16(data: bytes) -> int:
  138. value = 0xFFFF
  139. for byte in data:
  140. value ^= byte
  141. for _ in range(8):
  142. value = ((value >> 1) ^ 0xA001) if value & 1 else value >> 1
  143. return value
  144. def with_crc(body: bytes) -> bytes:
  145. value = crc16(body)
  146. return body + bytes((value & 0xFF, value >> 8))
  147. def read_holding_request(slave: int, start: int, count: int) -> bytes:
  148. return with_crc(bytes((slave, 0x03)) + start.to_bytes(2, "big") + count.to_bytes(2, "big"))
  149. def write_register_request(slave: int, address: int, value: int) -> bytes:
  150. return with_crc(bytes((slave, 0x06)) + address.to_bytes(2, "big") + value.to_bytes(2, "big"))
  151. def write_registers_request(slave: int, address: int, values: Sequence[int]) -> bytes:
  152. if not 1 <= len(values) <= 123:
  153. raise ValueError("multiple-register write count must be 1..123")
  154. payload = b"".join(int(v).to_bytes(2, "big") for v in values)
  155. body = bytes((slave, 0x10)) + address.to_bytes(2, "big") + len(values).to_bytes(2, "big")
  156. return with_crc(body + bytes((len(payload),)) + payload)
  157. def _frame_length(buffer: bytearray, offset: int, expected_function: int) -> Optional[int]:
  158. if len(buffer) - offset < 2:
  159. return None
  160. function = buffer[offset + 1]
  161. if function == (expected_function | 0x80):
  162. return 5
  163. if function != expected_function:
  164. return -1
  165. if function in (0x01, 0x03):
  166. if len(buffer) - offset < 3:
  167. return None
  168. return 5 + buffer[offset + 2]
  169. if function in (0x05, 0x06, 0x0F, 0x10):
  170. return 8
  171. return -1
  172. def extract_response(buffer: bytearray, slave: int, expected_function: int) -> Optional[bytes]:
  173. """Find a valid response while tolerating stale bytes and fragmentation."""
  174. for offset in range(max(0, len(buffer) - 260), len(buffer)):
  175. if buffer[offset] != slave:
  176. continue
  177. length = _frame_length(buffer, offset, expected_function)
  178. if length is None:
  179. continue
  180. if length < 0 or len(buffer) - offset < length:
  181. continue
  182. candidate = bytes(buffer[offset : offset + length])
  183. if int.from_bytes(candidate[-2:], "little") == crc16(candidate[:-2]):
  184. del buffer[: offset + length]
  185. return candidate
  186. if len(buffer) > 512:
  187. del buffer[:-260]
  188. return None
  189. def encode_u32(value: int) -> tuple[int, int]:
  190. if not 0 <= value <= 0xFFFFFFFF:
  191. raise ValueError("unsigned 32-bit value must be 0..4294967295")
  192. return (value >> 16) & 0xFFFF, value & 0xFFFF
  193. def encode_i32(value: int) -> tuple[int, int]:
  194. if not -0x80000000 <= value <= 0x7FFFFFFF:
  195. raise ValueError("signed 32-bit value must be -2147483648..2147483647")
  196. return encode_u32(value & 0xFFFFFFFF)
  197. def decode_u32(high: int, low: int) -> int:
  198. return ((high & 0xFFFF) << 16) | (low & 0xFFFF)
  199. def decode_i32(high: int, low: int) -> int:
  200. value = decode_u32(high, low)
  201. return value - 0x100000000 if value & 0x80000000 else value
  202. def validate_frequency(value: int, *, allow_zero: bool = False) -> None:
  203. """Validate a frequency value used by the PLSR protocol."""
  204. minimum = 0 if allow_zero else 1
  205. if not minimum <= value <= 100_000:
  206. lower = "0" if allow_zero else "1"
  207. raise ValueError(f"frequency must be {lower}..100000 Hz")
  208. @dataclass
  209. class SegmentParameters:
  210. frequency_hz: int = 1000
  211. pulses: int = 0
  212. wait_condition: int = 0
  213. wait_time_ms: int = 0
  214. act_time_ms: int = 0
  215. jump_segment: int = 0
  216. def validate(self, index: int, total_segments: int = 10) -> None:
  217. validate_frequency(self.frequency_hz, allow_zero=True)
  218. if not -0x80000000 <= self.pulses <= 0x7FFFFFFF:
  219. raise ValueError(f"segment {index}: pulses must be signed 32-bit")
  220. if self.wait_condition not in WAIT_CONDITION_NAMES:
  221. raise ValueError(f"segment {index}: wait condition must be 0..4")
  222. if not 0 <= self.wait_time_ms <= 0xFFFF or not 0 <= self.act_time_ms <= 0xFFFF:
  223. raise ValueError(f"segment {index}: wait/act time must be 0..65535 ms")
  224. if not 0 <= self.jump_segment <= total_segments:
  225. raise ValueError(f"segment {index}: jump segment must be 0..{total_segments}")
  226. @dataclass
  227. class PlsrConfiguration:
  228. pulse_output: int = 0
  229. direction_output: int = 0
  230. wait_input: int = 0
  231. ext_input: int = 0
  232. send_mode: int = 0
  233. direction_delay_ms: int = 0
  234. direction_logic: int = 0
  235. accel_mode: int = 0
  236. run_mode: int = 0
  237. segment_count: int = 1
  238. start_segment: int = 1
  239. default_speed_hz: int = 1000
  240. start_speed_hz: int = 1
  241. end_speed_hz: int = 1
  242. accel_time_ms: int = 0
  243. decel_time_ms: int = 0
  244. segments: list[SegmentParameters] = field(default_factory=lambda: [SegmentParameters() for _ in range(10)])
  245. def validate(self) -> None:
  246. if not 0 <= self.pulse_output <= 3:
  247. raise ValueError("pulse output must be Y0..Y3 (0..3)")
  248. if not 0 <= self.direction_output <= 3:
  249. raise ValueError("direction output must be Y12..Y15 (0..3)")
  250. if not 0 <= self.wait_input <= 1 or not 0 <= self.ext_input <= 1:
  251. raise ValueError("WAIT/EXT input must be X4 or X5 (0..1)")
  252. if self.send_mode not in (0, 1):
  253. raise ValueError("send mode must be 0 (complete) or 1 (follow-up)")
  254. if self.direction_logic not in (0, 1):
  255. raise ValueError("direction logic must be 0 (positive) or 1 (negative)")
  256. if self.accel_mode not in (0, 1, 2):
  257. raise ValueError("acceleration mode must be 0, 1 or 2")
  258. if self.run_mode not in (0, 1):
  259. raise ValueError("run mode must be 0 (relative) or 1 (absolute)")
  260. if not 0 <= self.direction_delay_ms <= 0xFFFF:
  261. raise ValueError("direction delay must be 0..65535 ms")
  262. if not 1 <= self.segment_count <= 10:
  263. raise ValueError("segment count must be 1..10")
  264. if not 1 <= self.start_segment <= self.segment_count:
  265. raise ValueError("start segment must be within 1..segment count")
  266. validate_frequency(self.default_speed_hz)
  267. validate_frequency(self.start_speed_hz, allow_zero=True)
  268. validate_frequency(self.end_speed_hz, allow_zero=True)
  269. if not 0 <= self.accel_time_ms <= 0xFFFF or not 0 <= self.decel_time_ms <= 0xFFFF:
  270. raise ValueError("acceleration/deceleration time must be 0..65535 ms")
  271. if len(self.segments) < 10:
  272. raise ValueError("configuration must contain 10 segment slots")
  273. for index in range(self.segment_count):
  274. self.segments[index].validate(index + 1, self.segment_count)
  275. @dataclass(frozen=True)
  276. class Snapshot:
  277. # First eight fields retain the legacy GUI/test API.
  278. state: int
  279. flags: int
  280. current_frequency: int
  281. stop_reason: int
  282. last_error: int
  283. total_pulses: int
  284. pulse_output: int
  285. direction_output: int
  286. current_segment: int = 0
  287. protocol: str = MAP_LEGACY
  288. @property
  289. def state_name(self) -> str:
  290. return STATE_NAMES.get(self.state, f"Unknown({self.state})")
  291. @property
  292. def error_name(self) -> str:
  293. return ERROR_NAMES.get(self.last_error, f"Unknown({self.last_error})")
  294. class ModbusRtuClient:
  295. def __init__(self, port: str, baud_rate: int = DEFAULT_BAUD_RATE, slave: int = SLAVE_ADDRESS,
  296. timeout: float = 0.6, retries: int = 2, verbose: bool = False,
  297. protocol: str = MAP_LEGACY) -> None:
  298. if protocol not in (MAP_SPEC, MAP_LEGACY):
  299. raise ValueError("protocol must be 'spec' or 'legacy'")
  300. self.port, self.baud_rate, self.slave = port, baud_rate, slave
  301. self.timeout, self.retries, self.verbose, self.protocol = timeout, retries, verbose, protocol
  302. self._serial = None
  303. def __enter__(self) -> "ModbusRtuClient":
  304. self.open()
  305. return self
  306. def __exit__(self, exc_type, exc_value, traceback) -> None:
  307. self.close()
  308. def open(self) -> None:
  309. if serial is None:
  310. raise PlsrError("pyserial is not installed; run: python -m pip install -r requirements.txt")
  311. self._serial = serial.Serial(port=self.port, baudrate=self.baud_rate, bytesize=serial.EIGHTBITS,
  312. parity=serial.PARITY_EVEN, stopbits=serial.STOPBITS_ONE,
  313. timeout=0.05, write_timeout=self.timeout)
  314. self._serial.reset_input_buffer()
  315. self._serial.reset_output_buffer()
  316. def close(self) -> None:
  317. if self._serial is not None:
  318. self._serial.close()
  319. self._serial = None
  320. def _log_frame(self, prefix: str, frame: bytes) -> None:
  321. if self.verbose:
  322. print(f"{time.strftime('%H:%M:%S')} {prefix} {frame.hex(' ').upper()}")
  323. def _receive(self, expected_function: int) -> bytes:
  324. if self._serial is None:
  325. raise CommunicationError("serial port is not open")
  326. deadline, buffer = time.monotonic() + self.timeout, bytearray()
  327. while time.monotonic() < deadline:
  328. waiting = self._serial.in_waiting
  329. chunk = self._serial.read(waiting if waiting > 0 else 1)
  330. if chunk:
  331. buffer.extend(chunk)
  332. response = extract_response(buffer, self.slave, expected_function)
  333. if response is not None:
  334. self._log_frame("RX", response)
  335. if response[1] == (expected_function | 0x80):
  336. raise ModbusException(expected_function, response[2])
  337. return response
  338. tail = bytes(buffer[-32:]).hex(" ").upper() if buffer else "<none>"
  339. raise CommunicationError(f"response timeout; received tail: {tail}")
  340. def transaction(self, request: bytes, expected_function: int) -> bytes:
  341. if self._serial is None:
  342. raise CommunicationError("serial port is not open")
  343. last_error: Optional[Exception] = None
  344. for attempt in range(self.retries + 1):
  345. if attempt:
  346. time.sleep(0.1)
  347. try:
  348. if self._serial is None:
  349. self.open()
  350. self._serial.reset_input_buffer()
  351. self._log_frame("TX", request)
  352. written = self._serial.write(request)
  353. self._serial.flush()
  354. if written != len(request):
  355. last_error = CommunicationError(
  356. f"short serial write: {written}/{len(request)}"
  357. )
  358. continue
  359. return self._receive(expected_function)
  360. except ModbusException:
  361. raise
  362. except CommunicationError as exc:
  363. last_error = exc
  364. if (attempt > 0) and (attempt < self.retries):
  365. try:
  366. self.close()
  367. self.open()
  368. except (OSError, PlsrError) as reopen_error:
  369. last_error = reopen_error
  370. except OSError as exc:
  371. last_error = exc
  372. if attempt < self.retries:
  373. try:
  374. self.close()
  375. self.open()
  376. except (OSError, PlsrError) as reopen_error:
  377. last_error = reopen_error
  378. raise CommunicationError(f"transaction failed after {self.retries + 1} attempts: {last_error}")
  379. def read_holding(self, start: int, count: int) -> list[int]:
  380. if not 0 <= start <= 0xFFFF or not 1 <= count <= 125:
  381. raise ValueError("invalid holding-register range")
  382. response = self.transaction(read_holding_request(self.slave, start, count), 0x03)
  383. if response[2] != count * 2:
  384. raise CommunicationError(f"unexpected byte count: {response[2]}, expected {count * 2}")
  385. payload = response[3:-2]
  386. return [int.from_bytes(payload[i:i + 2], "big") for i in range(0, len(payload), 2)]
  387. def write_register(self, address: int, value: int) -> None:
  388. if not 0 <= address <= 0xFFFF or not 0 <= value <= 0xFFFF:
  389. raise ValueError("register address/value out of range")
  390. request = write_register_request(self.slave, address, value)
  391. if self.transaction(request, 0x06) != request:
  392. raise CommunicationError("write response does not echo the request")
  393. def write_registers(self, address: int, values: Sequence[int]) -> None:
  394. request = write_registers_request(self.slave, address, values)
  395. response = self.transaction(request, 0x10)
  396. if response[:6] != request[:6] or int.from_bytes(response[-2:], "little") != crc16(response[:-2]):
  397. raise CommunicationError("multiple-write response does not echo address/count")
  398. def read_u32(self, address: int) -> int:
  399. values = self.read_holding(address, 2)
  400. return decode_u32(values[0], values[1])
  401. def read_i32(self, address: int) -> int:
  402. values = self.read_holding(address, 2)
  403. return decode_i32(values[0], values[1])
  404. def write_u32(self, address: int, value: int) -> None:
  405. self.write_registers(address, encode_u32(value))
  406. def write_i32(self, address: int, value: int) -> None:
  407. self.write_registers(address, encode_i32(value))
  408. def set_running_frequency(self, frequency: int) -> Snapshot:
  409. """修改当前运行段频率,不重新启动脉冲任务。"""
  410. validate_frequency(frequency)
  411. if self.protocol == MAP_LEGACY:
  412. self.write_register(REG_FREQUENCY, frequency)
  413. return self.read_snapshot()
  414. snapshot = self.read_snapshot()
  415. if snapshot.state not in (1, 2, 3, 4):
  416. raise PlsrError("motion is not running")
  417. if not 1 <= snapshot.current_segment <= 10:
  418. raise PlsrError("current segment is invalid")
  419. address = REG_SEGMENT_BASE + (snapshot.current_segment - 1) * REG_SEGMENT_STRIDE
  420. self.write_u32(address, frequency)
  421. return self.read_snapshot()
  422. def command(self, value: int) -> None:
  423. if self.protocol == MAP_LEGACY:
  424. self.write_register(REG_COMMAND, value)
  425. return
  426. if value == COMMAND_START:
  427. self.write_register(REG_CONTROL, CONTROL_START)
  428. elif value == COMMAND_STOP:
  429. self.write_register(REG_CONTROL, CONTROL_STOP)
  430. elif value == COMMAND_IMMEDIATE_STOP:
  431. self.write_register(REG_CONTROL, CONTROL_IMMEDIATE_STOP)
  432. elif value == COMMAND_CLEAR_COUNT:
  433. self.write_register(REG_CONTROL, CONTROL_CLEAR_COUNT)
  434. elif value == COMMAND_RESET:
  435. self.write_register(REG_CONTROL, CONTROL_RESET)
  436. elif value == COMMAND_RESTART:
  437. self.write_register(REG_CONTROL, CONTROL_RESTART)
  438. elif value == COMMAND_SAVE_CONFIG:
  439. raise PlsrError("the requirement map has no save bit; configuration persistence is firmware-managed")
  440. else:
  441. raise ValueError(f"unsupported command: {value}")
  442. def read_snapshot(self) -> Snapshot:
  443. if self.protocol == MAP_LEGACY:
  444. status = self.read_holding(0, 32)
  445. outputs = self.read_holding(REG_PULSE_OUTPUT, 2)
  446. return Snapshot(status[REG_STATE], status[REG_FLAGS], status[REG_CURRENT_FREQUENCY],
  447. status[REG_STOP_REASON], status[REG_LAST_ERROR],
  448. decode_u32(status[REG_TOTAL_PULSES_HIGH], status[REG_TOTAL_PULSES_LOW]),
  449. outputs[0], outputs[1])
  450. monitor = self.read_holding(REG_MONITOR_TOTAL_PULSES, 8)
  451. config = self.read_holding(REG_CFG_PULSE_OUTPUT, 2)
  452. state = monitor[4]
  453. flags = 1 if state in (1, 2, 3, 4) else (2 if state == 5 else (4 if state == 7 else 0))
  454. return Snapshot(state, flags, decode_u32(monitor[2], monitor[3]), monitor[7], monitor[6],
  455. decode_i32(monitor[0], monitor[1]), config[0], config[1], monitor[5], MAP_SPEC)
  456. def read_configuration(self) -> PlsrConfiguration:
  457. if self.protocol != MAP_SPEC:
  458. raise PlsrError("configuration object is only available in spec protocol")
  459. values = self.read_holding(REG_CFG_PULSE_OUTPUT, REG_CFG_DECEL_TIME_MS - REG_CFG_PULSE_OUTPUT + 1)
  460. cfg = PlsrConfiguration(
  461. pulse_output=values[0], direction_output=values[1], wait_input=values[2], ext_input=values[3],
  462. send_mode=values[4], direction_delay_ms=values[5], direction_logic=values[6], accel_mode=values[7],
  463. run_mode=values[8], segment_count=values[9], start_segment=values[10],
  464. default_speed_hz=decode_u32(values[11], values[12]), start_speed_hz=decode_u32(values[13], values[14]),
  465. end_speed_hz=decode_u32(values[16], values[17]), accel_time_ms=values[18], decel_time_ms=values[19],
  466. )
  467. cfg.segments = [self.read_segment(index + 1) for index in range(10)]
  468. return cfg
  469. def write_configuration(self, config: PlsrConfiguration, save: bool = False) -> None:
  470. if self.protocol != MAP_SPEC:
  471. raise PlsrError("configuration object is only available in spec protocol")
  472. config.validate()
  473. values = [config.pulse_output, config.direction_output, config.wait_input, config.ext_input,
  474. config.send_mode, config.direction_delay_ms, config.direction_logic, config.accel_mode,
  475. config.run_mode, config.segment_count, config.start_segment]
  476. values.extend(encode_u32(config.default_speed_hz))
  477. values.extend(encode_u32(config.start_speed_hz))
  478. values.append(0) # 0x100F reserved
  479. values.extend(encode_u32(config.end_speed_hz))
  480. values.extend((config.accel_time_ms, config.decel_time_ms))
  481. self.write_registers(REG_CFG_PULSE_OUTPUT, values)
  482. for index in range(1, config.segment_count + 1):
  483. self.write_segment(index, config.segments[index - 1])
  484. if save:
  485. # There is no save register in the revised parameter table. Keep
  486. # the argument for GUI compatibility and make the limitation clear.
  487. raise PlsrError("configuration written to RAM; add the firmware save command before requesting Flash save")
  488. def read_segment(self, index: int) -> SegmentParameters:
  489. if self.protocol != MAP_SPEC or not 1 <= index <= 10:
  490. raise ValueError("segment index must be 1..10")
  491. base = REG_SEGMENT_BASE + (index - 1) * REG_SEGMENT_STRIDE
  492. values = self.read_holding(base, 8)
  493. return SegmentParameters(decode_u32(values[0], values[1]), decode_i32(values[2], values[3]),
  494. values[4], values[5], values[6], values[7])
  495. def write_segment(self, index: int, segment: SegmentParameters) -> None:
  496. if self.protocol != MAP_SPEC or not 1 <= index <= 10:
  497. raise ValueError("segment index must be 1..10")
  498. segment.validate(index)
  499. base = REG_SEGMENT_BASE + (index - 1) * REG_SEGMENT_STRIDE
  500. self.write_registers(base, [*encode_u32(segment.frequency_hz), *encode_i32(segment.pulses),
  501. segment.wait_condition, segment.wait_time_ms, segment.act_time_ms,
  502. segment.jump_segment])
  503. def configure_outputs(self, pulse_output: int, direction_output: int, save: bool = False) -> Snapshot:
  504. if self.protocol == MAP_LEGACY:
  505. if not 0 <= pulse_output <= 7 or not 0 <= direction_output <= 7 or pulse_output == direction_output:
  506. raise ValueError("legacy output points must be distinct Q0..Q7")
  507. snapshot = self.read_snapshot()
  508. if snapshot.state == 2:
  509. raise PlsrError("configuration is blocked while motion is Busy")
  510. self.write_register(REG_PULSE_OUTPUT, pulse_output)
  511. self.write_register(REG_DIRECTION_OUTPUT, direction_output)
  512. if save:
  513. self.command(COMMAND_SAVE_CONFIG)
  514. return self.read_snapshot()
  515. if not 0 <= pulse_output <= 3 or not 0 <= direction_output <= 3:
  516. raise ValueError("outputs must be Y0..Y3 and Y12..Y15")
  517. if self.read_snapshot().state in (1, 2, 3, 4):
  518. raise PlsrError("configuration is blocked while motion is active")
  519. self.write_register(REG_CFG_PULSE_OUTPUT, pulse_output)
  520. self.write_register(REG_CFG_DIRECTION_OUTPUT, direction_output)
  521. return self.read_snapshot()
  522. def start_motion(self, pulses: int, frequency: int, direction: int = 0,
  523. restart: bool = False) -> None:
  524. if self.protocol == MAP_LEGACY:
  525. if not 1 <= pulses <= 0xFFFFFFFF:
  526. raise ValueError("legacy pulses must be 1..4294967295")
  527. if not 1 <= frequency <= 1000:
  528. raise ValueError("legacy firmware test module supports 1..1000 Hz")
  529. if direction not in (0, 1):
  530. raise ValueError("direction must be 0 or 1")
  531. self.write_register(REG_PULSES_HIGH, (pulses >> 16) & 0xFFFF)
  532. self.write_register(REG_PULSES_LOW, pulses & 0xFFFF)
  533. self.write_register(REG_FREQUENCY, frequency)
  534. self.write_register(REG_DIRECTION, direction)
  535. self.command(COMMAND_RESTART if restart else COMMAND_START)
  536. return
  537. validate_frequency(frequency)
  538. if pulses == 0 or not -0x80000000 <= pulses <= 0x7FFFFFFF:
  539. raise ValueError("spec pulse count must be non-zero signed 32-bit")
  540. if direction not in (0, 1):
  541. raise ValueError("direction must be 0 or 1")
  542. if direction == 1 and pulses > 0:
  543. pulses = -pulses
  544. cfg = self.read_configuration()
  545. cfg.segments[cfg.start_segment - 1] = SegmentParameters(frequency, pulses)
  546. cfg.segment_count = max(cfg.segment_count, cfg.start_segment)
  547. cfg.validate()
  548. self.write_segment(cfg.start_segment, cfg.segments[cfg.start_segment - 1])
  549. self.command(COMMAND_RESTART if restart else COMMAND_START)
  550. def print_snapshot(snapshot: Snapshot) -> None:
  551. print(f"state={snapshot.state_name:<14} segment={snapshot.current_segment:<2} "
  552. f"frequency={snapshot.current_frequency}Hz total_pulses={snapshot.total_pulses} "
  553. f"error={snapshot.last_error}:{snapshot.error_name} outputs={snapshot.pulse_output}/{snapshot.direction_output}")
  554. def wait_for_terminal(client: ModbusRtuClient, timeout: float, interval: float = 0.25) -> Snapshot:
  555. deadline, previous = time.monotonic() + timeout, None
  556. while time.monotonic() < deadline:
  557. snapshot = client.read_snapshot()
  558. current = (snapshot.state, snapshot.total_pulses, snapshot.last_error)
  559. if current != previous:
  560. print_snapshot(snapshot)
  561. previous = current
  562. if snapshot.state in (5, 6, 7):
  563. return snapshot
  564. time.sleep(interval)
  565. try:
  566. client.command(COMMAND_STOP)
  567. except PlsrError:
  568. pass
  569. raise PlsrError("motion timeout; stop command was attempted")
  570. def require_safe_confirmation(args: argparse.Namespace) -> None:
  571. if not args.confirm_safe:
  572. raise PlsrError("motion output is blocked; disconnect the mechanism and add --confirm-safe")
  573. def offline_self_test() -> None:
  574. assert crc16(bytes.fromhex("01 03 00 00 00 0A")) == 0xCDC5
  575. assert read_holding_request(1, 0, 32).hex(" ") == "01 03 00 00 00 20 44 12"
  576. assert encode_i32(-1) == (0xFFFF, 0xFFFF)
  577. assert decode_i32(0x8000, 0) == -2147483648
  578. request = write_registers_request(1, REG_SEGMENT_BASE, [100, 0, 0xFFFF, 0xFFFF, 0, 0, 0, 0])
  579. assert request[1] == 0x10 and request[6] == 16
  580. response = with_crc(bytes.fromhex("01 03 04 00 05 00 06"))
  581. noisy = bytearray(bytes.fromhex("40 00 00") + response)
  582. assert extract_response(noisy, 1, 0x03) == response
  583. print("PASS CRC16, signed double-word, FC16 and response resynchronization")
  584. def add_connection_options(parser: argparse.ArgumentParser) -> None:
  585. parser.add_argument("--port", help="serial port, for example COM9")
  586. parser.add_argument("--baud", type=int, default=DEFAULT_BAUD_RATE)
  587. parser.add_argument("--slave", type=int, default=SLAVE_ADDRESS)
  588. parser.add_argument("--timeout", type=float, default=0.6)
  589. parser.add_argument("--retries", type=int, default=2)
  590. parser.add_argument("--map", choices=(MAP_SPEC, MAP_LEGACY), default=MAP_SPEC,
  591. help="spec follows 参数表-2026.xlsx; legacy follows the first firmware image")
  592. parser.add_argument("-v", "--verbose", action="store_true")
  593. def build_parser() -> argparse.ArgumentParser:
  594. parser = argparse.ArgumentParser(description="PLSR firmware Modbus RTU test tool")
  595. add_connection_options(parser)
  596. subs = parser.add_subparsers(dest="command_name", required=True)
  597. subs.add_parser("ports")
  598. subs.add_parser("self-test")
  599. subs.add_parser("mandatory-list")
  600. subs.add_parser("status")
  601. monitor = subs.add_parser("monitor"); monitor.add_argument("--interval", type=float, default=0.5)
  602. run = subs.add_parser("run"); run.add_argument("--pulses", type=int, default=10); run.add_argument("--frequency", type=int, default=2)
  603. run.add_argument("--direction", choices=("forward", "reverse"), default="forward"); run.add_argument("--wait-timeout", type=float, default=30.0); run.add_argument("--no-wait", action="store_true"); run.add_argument("--confirm-safe", action="store_true")
  604. subs.add_parser("stop"); subs.add_parser("immediate-stop"); subs.add_parser("reset"); subs.add_parser("clear")
  605. config = subs.add_parser("configure"); config.add_argument("--pulse-output", type=int, required=True); config.add_argument("--direction-output", type=int, required=True); config.add_argument("--save", action="store_true")
  606. auto = subs.add_parser("auto-test"); auto.add_argument("--pulses", type=int, default=6); auto.add_argument("--frequency", type=int, default=2); auto.add_argument("--confirm-safe", action="store_true"); auto.add_argument("--wait-timeout", type=float, default=30.0)
  607. return parser
  608. def run_connected_command(args: argparse.Namespace) -> None:
  609. if not args.port:
  610. raise PlsrError("--port is required for this command")
  611. with ModbusRtuClient(args.port, args.baud, args.slave, args.timeout, args.retries, args.verbose, args.map) as client:
  612. if args.command_name == "status": print_snapshot(client.read_snapshot())
  613. elif args.command_name == "monitor":
  614. while True: print_snapshot(client.read_snapshot()); time.sleep(max(0.1, args.interval))
  615. elif args.command_name == "run":
  616. require_safe_confirmation(args); client.start_motion(args.pulses, args.frequency, int(args.direction == "reverse"))
  617. if not args.no_wait and wait_for_terminal(client, args.wait_timeout).state != 5: raise PlsrError("motion did not finish normally")
  618. elif args.command_name == "configure": print_snapshot(client.configure_outputs(args.pulse_output, args.direction_output, args.save))
  619. elif args.command_name == "stop": client.command(COMMAND_STOP); print_snapshot(client.read_snapshot())
  620. elif args.command_name == "immediate-stop": client.command(COMMAND_IMMEDIATE_STOP); print_snapshot(client.read_snapshot())
  621. elif args.command_name == "reset": client.command(COMMAND_RESET); print_snapshot(client.read_snapshot())
  622. elif args.command_name == "clear": client.command(COMMAND_CLEAR_COUNT); print_snapshot(client.read_snapshot())
  623. elif args.command_name == "auto-test":
  624. require_safe_confirmation(args); client.command(COMMAND_CLEAR_COUNT); client.start_motion(args.pulses, args.frequency, 0); first = wait_for_terminal(client, args.wait_timeout)
  625. if first.state != 5: raise PlsrError("forward test failed")
  626. client.start_motion(args.pulses, args.frequency, 1); second = wait_for_terminal(client, args.wait_timeout)
  627. if second.state != 5: raise PlsrError("reverse test failed")
  628. print("PASS forward/reverse mandatory smoke test")
  629. def main(argv: Optional[Sequence[str]] = None) -> int:
  630. args = build_parser().parse_args(argv)
  631. try:
  632. if args.command_name == "self-test": offline_self_test()
  633. elif args.command_name == "mandatory-list": print(mandatory_test_checklist())
  634. elif args.command_name == "ports":
  635. if list_ports is None: raise PlsrError("pyserial is not installed")
  636. for item in sorted(list_ports.comports(), key=lambda p: p.device): print(f"{item.device:<8} {item.description}")
  637. else: run_connected_command(args)
  638. return 0
  639. except KeyboardInterrupt: return 130
  640. except (OSError, ValueError, PlsrError) as exc:
  641. print(f"ERROR: {exc}", file=sys.stderr); return 1
  642. if __name__ == "__main__":
  643. raise SystemExit(main())