#!/usr/bin/env python3 """PLSR Modbus RTU test client. The ``spec`` map follows ``document/参数表-2026.xlsx``. ``legacy`` keeps compatibility with the first single-segment firmware image in this project. """ from __future__ import annotations import argparse import sys import time from dataclasses import dataclass, field from typing import Optional, Sequence try: import serial from serial.tools import list_ports except ImportError: serial = None list_ports = None SLAVE_ADDRESS = 1 DEFAULT_BAUD_RATE = 115200 MAP_SPEC = "spec" MAP_LEGACY = "legacy" # Requirement-map addresses. These are holding-register offsets, not 4xxxx # display addresses. A 32-bit value uses high word first, low word second. REG_CFG_PULSE_OUTPUT = 0x1000 REG_CFG_DIRECTION_OUTPUT = 0x1001 REG_CFG_WAIT_INPUT = 0x1002 REG_CFG_EXT_INPUT = 0x1003 REG_CFG_SEND_MODE = 0x1004 REG_CFG_DIRECTION_DELAY_MS = 0x1005 REG_CFG_DIRECTION_LOGIC = 0x1006 REG_CFG_ACCEL_MODE = 0x1007 REG_CFG_RUN_MODE = 0x1008 REG_CFG_SEGMENT_COUNT = 0x1009 REG_CFG_START_SEGMENT = 0x100A REG_CFG_DEFAULT_SPEED = 0x100B REG_CFG_START_SPEED = 0x100D REG_CFG_END_SPEED = 0x1010 REG_CFG_ACCEL_TIME_MS = 0x1012 REG_CFG_DECEL_TIME_MS = 0x1013 REG_SEGMENT_BASE = 0x1100 REG_SEGMENT_STRIDE = 0x10 REG_MONITOR_TOTAL_PULSES = 0x2000 REG_MONITOR_FREQUENCY = 0x2002 REG_MONITOR_STATE = 0x2004 REG_MONITOR_SEGMENT = 0x2005 REG_MONITOR_ERROR = 0x2006 REG_MONITOR_STOP_REASON = 0x2007 REG_CONTROL = 0x3000 # Legacy zero-based map used by the current minimal firmware image. REG_STATE = 0 REG_FLAGS = 1 REG_CURRENT_FREQUENCY = 3 REG_STOP_REASON = 5 REG_LAST_ERROR = 6 REG_TOTAL_PULSES_HIGH = 10 REG_TOTAL_PULSES_LOW = 11 REG_COMMAND = 100 REG_PULSES_HIGH = 102 REG_PULSES_LOW = 103 REG_FREQUENCY = 104 REG_DIRECTION = 105 REG_PULSE_OUTPUT = 106 REG_DIRECTION_OUTPUT = 107 COMMAND_START = 1 COMMAND_STOP = 2 COMMAND_RESET = 3 COMMAND_CLEAR_COUNT = 4 COMMAND_IMMEDIATE_STOP = 5 COMMAND_SAVE_CONFIG = 6 COMMAND_RESTART = 7 CONTROL_START = 1 << 0 CONTROL_STOP = 1 << 1 CONTROL_CLEAR_COUNT = 1 << 2 CONTROL_RESTART = 1 << 3 CONTROL_RESET = 1 << 4 CONTROL_IMMEDIATE_STOP = 1 << 5 STATE_NAMES = { 0: "Idle", 1: "Armed", 2: "Busy", 3: "Wait", 4: "Decelerating", 5: "Done", 6: "Stopped", 7: "Error", } ERROR_NAMES = { 0: "OK", 1: "Invalid argument", 2: "Busy", 3: "Resource", 4: "State", 5: "Wait timeout", 6: "Path range", 7: "Loop limit", 8: "External stop", 9: "Config CRC", 10: "Communication", 11: "Timer", } WAIT_CONDITION_NAMES = { 0: "WAIT time", 1: "WAIT signal", 2: "ACT time", 3: "EXT signal", 4: "EXT signal or pulse done", } MANDATORY_TEST_ITEMS = ( ("M01", "Y0..Y3 pulse output selection", "automatic register round-trip + oscilloscope"), ("M02", "Y12..Y15 direction output selection and logic", "automatic register round-trip + level observation"), ("M03", "positive/negative signed pulse count", "automatic motion/count + direction observation"), ("M04", "1..100000 Hz and online frequency update", "register boundary + oscilloscope/frequency counter"), ("M05", "complete/follow-up send mode", "state transition observation"), ("M06", "relative/absolute mode including equal-position no-op", "motion/count observation"), ("M07", "linear/S-curve/sine acceleration", "configuration round-trip + oscilloscope capture"), ("M08", "start/default/end speed and acceleration/deceleration time", "configuration round-trip + waveform timing"), ("M09", "1..10 segments and selectable start segment", "10-segment round-trip + execution sequence"), ("M10", "WAIT time and WAIT signal", "timing + manual X4/X5 trigger"), ("M11", "ACT time and EXT signal", "timing + manual X4/X5 trigger"), ("M12", "EXT signal or pulse completion", "manual EXT trigger and natural completion branches"), ("M13", "jump=0/default next/end and explicit jump", "current-segment monitor sequence"), ("M14", "cumulative signed count and communication clear", "automatic monitor/control check"), ("M15", "stop, state, current segment and error diagnostics", "automatic state/error monitor check"), ("M16", "power-loss save/load and power-up parameter restore", "manual power-cycle and readback"), ) def mandatory_test_checklist() -> str: return "\n".join(f"{code} {name} [{method}]" for code, name, method in MANDATORY_TEST_ITEMS) class PlsrError(RuntimeError): pass class CommunicationError(PlsrError): pass class ModbusException(PlsrError): def __init__(self, function: int, code: int) -> None: super().__init__(f"Modbus exception: function=0x{function:02X}, code=0x{code:02X}") self.function = function self.code = code def crc16(data: bytes) -> int: value = 0xFFFF for byte in data: value ^= byte for _ in range(8): value = ((value >> 1) ^ 0xA001) if value & 1 else value >> 1 return value def with_crc(body: bytes) -> bytes: value = crc16(body) return body + bytes((value & 0xFF, value >> 8)) def read_holding_request(slave: int, start: int, count: int) -> bytes: return with_crc(bytes((slave, 0x03)) + start.to_bytes(2, "big") + count.to_bytes(2, "big")) def write_register_request(slave: int, address: int, value: int) -> bytes: return with_crc(bytes((slave, 0x06)) + address.to_bytes(2, "big") + value.to_bytes(2, "big")) def write_registers_request(slave: int, address: int, values: Sequence[int]) -> bytes: if not 1 <= len(values) <= 123: raise ValueError("multiple-register write count must be 1..123") payload = b"".join(int(v).to_bytes(2, "big") for v in values) body = bytes((slave, 0x10)) + address.to_bytes(2, "big") + len(values).to_bytes(2, "big") return with_crc(body + bytes((len(payload),)) + payload) def _frame_length(buffer: bytearray, offset: int, expected_function: int) -> Optional[int]: if len(buffer) - offset < 2: return None function = buffer[offset + 1] if function == (expected_function | 0x80): return 5 if function != expected_function: return -1 if function in (0x01, 0x03): if len(buffer) - offset < 3: return None return 5 + buffer[offset + 2] if function in (0x05, 0x06, 0x0F, 0x10): return 8 return -1 def extract_response(buffer: bytearray, slave: int, expected_function: int) -> Optional[bytes]: """Find a valid response while tolerating stale bytes and fragmentation.""" for offset in range(max(0, len(buffer) - 260), len(buffer)): if buffer[offset] != slave: continue length = _frame_length(buffer, offset, expected_function) if length is None: continue if length < 0 or len(buffer) - offset < length: continue candidate = bytes(buffer[offset : offset + length]) if int.from_bytes(candidate[-2:], "little") == crc16(candidate[:-2]): del buffer[: offset + length] return candidate if len(buffer) > 512: del buffer[:-260] return None def encode_u32(value: int) -> tuple[int, int]: if not 0 <= value <= 0xFFFFFFFF: raise ValueError("unsigned 32-bit value must be 0..4294967295") return (value >> 16) & 0xFFFF, value & 0xFFFF def encode_i32(value: int) -> tuple[int, int]: if not -0x80000000 <= value <= 0x7FFFFFFF: raise ValueError("signed 32-bit value must be -2147483648..2147483647") return encode_u32(value & 0xFFFFFFFF) def decode_u32(high: int, low: int) -> int: return ((high & 0xFFFF) << 16) | (low & 0xFFFF) def decode_i32(high: int, low: int) -> int: value = decode_u32(high, low) return value - 0x100000000 if value & 0x80000000 else value def validate_frequency(value: int, *, allow_zero: bool = False) -> None: """Validate a frequency value used by the PLSR protocol.""" minimum = 0 if allow_zero else 1 if not minimum <= value <= 100_000: lower = "0" if allow_zero else "1" raise ValueError(f"frequency must be {lower}..100000 Hz") @dataclass class SegmentParameters: frequency_hz: int = 1000 pulses: int = 0 wait_condition: int = 0 wait_time_ms: int = 0 act_time_ms: int = 0 jump_segment: int = 0 def validate(self, index: int, total_segments: int = 10) -> None: validate_frequency(self.frequency_hz, allow_zero=True) if not -0x80000000 <= self.pulses <= 0x7FFFFFFF: raise ValueError(f"segment {index}: pulses must be signed 32-bit") if self.wait_condition not in WAIT_CONDITION_NAMES: raise ValueError(f"segment {index}: wait condition must be 0..4") if not 0 <= self.wait_time_ms <= 0xFFFF or not 0 <= self.act_time_ms <= 0xFFFF: raise ValueError(f"segment {index}: wait/act time must be 0..65535 ms") if not 0 <= self.jump_segment <= total_segments: raise ValueError(f"segment {index}: jump segment must be 0..{total_segments}") @dataclass class PlsrConfiguration: pulse_output: int = 0 direction_output: int = 0 wait_input: int = 0 ext_input: int = 0 send_mode: int = 0 direction_delay_ms: int = 0 direction_logic: int = 0 accel_mode: int = 0 run_mode: int = 0 segment_count: int = 1 start_segment: int = 1 default_speed_hz: int = 1000 start_speed_hz: int = 1 end_speed_hz: int = 1 accel_time_ms: int = 0 decel_time_ms: int = 0 segments: list[SegmentParameters] = field(default_factory=lambda: [SegmentParameters() for _ in range(10)]) def validate(self) -> None: if not 0 <= self.pulse_output <= 3: raise ValueError("pulse output must be Y0..Y3 (0..3)") if not 0 <= self.direction_output <= 3: raise ValueError("direction output must be Y12..Y15 (0..3)") if not 0 <= self.wait_input <= 1 or not 0 <= self.ext_input <= 1: raise ValueError("WAIT/EXT input must be X4 or X5 (0..1)") if self.send_mode not in (0, 1): raise ValueError("send mode must be 0 (complete) or 1 (follow-up)") if self.direction_logic not in (0, 1): raise ValueError("direction logic must be 0 (positive) or 1 (negative)") if self.accel_mode not in (0, 1, 2): raise ValueError("acceleration mode must be 0, 1 or 2") if self.run_mode not in (0, 1): raise ValueError("run mode must be 0 (relative) or 1 (absolute)") if not 0 <= self.direction_delay_ms <= 0xFFFF: raise ValueError("direction delay must be 0..65535 ms") if not 1 <= self.segment_count <= 10: raise ValueError("segment count must be 1..10") if not 1 <= self.start_segment <= self.segment_count: raise ValueError("start segment must be within 1..segment count") validate_frequency(self.default_speed_hz) validate_frequency(self.start_speed_hz, allow_zero=True) validate_frequency(self.end_speed_hz, allow_zero=True) if not 0 <= self.accel_time_ms <= 0xFFFF or not 0 <= self.decel_time_ms <= 0xFFFF: raise ValueError("acceleration/deceleration time must be 0..65535 ms") if len(self.segments) < 10: raise ValueError("configuration must contain 10 segment slots") for index in range(self.segment_count): self.segments[index].validate(index + 1, self.segment_count) @dataclass(frozen=True) class Snapshot: # First eight fields retain the legacy GUI/test API. state: int flags: int current_frequency: int stop_reason: int last_error: int total_pulses: int pulse_output: int direction_output: int current_segment: int = 0 protocol: str = MAP_LEGACY @property def state_name(self) -> str: return STATE_NAMES.get(self.state, f"Unknown({self.state})") @property def error_name(self) -> str: return ERROR_NAMES.get(self.last_error, f"Unknown({self.last_error})") class ModbusRtuClient: def __init__(self, port: str, baud_rate: int = DEFAULT_BAUD_RATE, slave: int = SLAVE_ADDRESS, timeout: float = 0.6, retries: int = 2, verbose: bool = False, protocol: str = MAP_LEGACY) -> None: if protocol not in (MAP_SPEC, MAP_LEGACY): raise ValueError("protocol must be 'spec' or 'legacy'") self.port, self.baud_rate, self.slave = port, baud_rate, slave self.timeout, self.retries, self.verbose, self.protocol = timeout, retries, verbose, protocol self._serial = None def __enter__(self) -> "ModbusRtuClient": self.open() return self def __exit__(self, exc_type, exc_value, traceback) -> None: self.close() def open(self) -> None: if serial is None: raise PlsrError("pyserial is not installed; run: python -m pip install -r requirements.txt") self._serial = serial.Serial(port=self.port, baudrate=self.baud_rate, bytesize=serial.EIGHTBITS, parity=serial.PARITY_EVEN, stopbits=serial.STOPBITS_ONE, timeout=0.05, write_timeout=self.timeout) self._serial.reset_input_buffer() self._serial.reset_output_buffer() def close(self) -> None: if self._serial is not None: self._serial.close() self._serial = None def _log_frame(self, prefix: str, frame: bytes) -> None: if self.verbose: print(f"{time.strftime('%H:%M:%S')} {prefix} {frame.hex(' ').upper()}") def _receive(self, expected_function: int) -> bytes: if self._serial is None: raise CommunicationError("serial port is not open") deadline, buffer = time.monotonic() + self.timeout, bytearray() while time.monotonic() < deadline: waiting = self._serial.in_waiting chunk = self._serial.read(waiting if waiting > 0 else 1) if chunk: buffer.extend(chunk) response = extract_response(buffer, self.slave, expected_function) if response is not None: self._log_frame("RX", response) if response[1] == (expected_function | 0x80): raise ModbusException(expected_function, response[2]) return response tail = bytes(buffer[-32:]).hex(" ").upper() if buffer else "" raise CommunicationError(f"response timeout; received tail: {tail}") def transaction(self, request: bytes, expected_function: int) -> bytes: if self._serial is None: raise CommunicationError("serial port is not open") last_error: Optional[Exception] = None for attempt in range(self.retries + 1): if attempt: time.sleep(0.1) try: if self._serial is None: self.open() self._serial.reset_input_buffer() self._log_frame("TX", request) written = self._serial.write(request) self._serial.flush() if written != len(request): last_error = CommunicationError( f"short serial write: {written}/{len(request)}" ) continue return self._receive(expected_function) except ModbusException: raise except CommunicationError as exc: last_error = exc if (attempt > 0) and (attempt < self.retries): try: self.close() self.open() except (OSError, PlsrError) as reopen_error: last_error = reopen_error except OSError as exc: last_error = exc if attempt < self.retries: try: self.close() self.open() except (OSError, PlsrError) as reopen_error: last_error = reopen_error raise CommunicationError(f"transaction failed after {self.retries + 1} attempts: {last_error}") def read_holding(self, start: int, count: int) -> list[int]: if not 0 <= start <= 0xFFFF or not 1 <= count <= 125: raise ValueError("invalid holding-register range") response = self.transaction(read_holding_request(self.slave, start, count), 0x03) if response[2] != count * 2: raise CommunicationError(f"unexpected byte count: {response[2]}, expected {count * 2}") payload = response[3:-2] return [int.from_bytes(payload[i:i + 2], "big") for i in range(0, len(payload), 2)] def write_register(self, address: int, value: int) -> None: if not 0 <= address <= 0xFFFF or not 0 <= value <= 0xFFFF: raise ValueError("register address/value out of range") request = write_register_request(self.slave, address, value) if self.transaction(request, 0x06) != request: raise CommunicationError("write response does not echo the request") def write_registers(self, address: int, values: Sequence[int]) -> None: request = write_registers_request(self.slave, address, values) response = self.transaction(request, 0x10) if response[:6] != request[:6] or int.from_bytes(response[-2:], "little") != crc16(response[:-2]): raise CommunicationError("multiple-write response does not echo address/count") def read_u32(self, address: int) -> int: values = self.read_holding(address, 2) return decode_u32(values[0], values[1]) def read_i32(self, address: int) -> int: values = self.read_holding(address, 2) return decode_i32(values[0], values[1]) def write_u32(self, address: int, value: int) -> None: self.write_registers(address, encode_u32(value)) def write_i32(self, address: int, value: int) -> None: self.write_registers(address, encode_i32(value)) def set_running_frequency(self, frequency: int) -> Snapshot: """修改当前运行段频率,不重新启动脉冲任务。""" validate_frequency(frequency) if self.protocol == MAP_LEGACY: self.write_register(REG_FREQUENCY, frequency) return self.read_snapshot() snapshot = self.read_snapshot() if snapshot.state not in (1, 2, 3, 4): raise PlsrError("motion is not running") if not 1 <= snapshot.current_segment <= 10: raise PlsrError("current segment is invalid") address = REG_SEGMENT_BASE + (snapshot.current_segment - 1) * REG_SEGMENT_STRIDE self.write_u32(address, frequency) return self.read_snapshot() def command(self, value: int) -> None: if self.protocol == MAP_LEGACY: self.write_register(REG_COMMAND, value) return if value == COMMAND_START: self.write_register(REG_CONTROL, CONTROL_START) elif value == COMMAND_STOP: self.write_register(REG_CONTROL, CONTROL_STOP) elif value == COMMAND_IMMEDIATE_STOP: self.write_register(REG_CONTROL, CONTROL_IMMEDIATE_STOP) elif value == COMMAND_CLEAR_COUNT: self.write_register(REG_CONTROL, CONTROL_CLEAR_COUNT) elif value == COMMAND_RESET: self.write_register(REG_CONTROL, CONTROL_RESET) elif value == COMMAND_RESTART: self.write_register(REG_CONTROL, CONTROL_RESTART) elif value == COMMAND_SAVE_CONFIG: raise PlsrError("the requirement map has no save bit; configuration persistence is firmware-managed") else: raise ValueError(f"unsupported command: {value}") def read_snapshot(self) -> Snapshot: if self.protocol == MAP_LEGACY: status = self.read_holding(0, 32) outputs = self.read_holding(REG_PULSE_OUTPUT, 2) return Snapshot(status[REG_STATE], status[REG_FLAGS], status[REG_CURRENT_FREQUENCY], status[REG_STOP_REASON], status[REG_LAST_ERROR], decode_u32(status[REG_TOTAL_PULSES_HIGH], status[REG_TOTAL_PULSES_LOW]), outputs[0], outputs[1]) monitor = self.read_holding(REG_MONITOR_TOTAL_PULSES, 8) config = self.read_holding(REG_CFG_PULSE_OUTPUT, 2) state = monitor[4] flags = 1 if state in (1, 2, 3, 4) else (2 if state == 5 else (4 if state == 7 else 0)) return Snapshot(state, flags, decode_u32(monitor[2], monitor[3]), monitor[7], monitor[6], decode_i32(monitor[0], monitor[1]), config[0], config[1], monitor[5], MAP_SPEC) def read_configuration(self) -> PlsrConfiguration: if self.protocol != MAP_SPEC: raise PlsrError("configuration object is only available in spec protocol") values = self.read_holding(REG_CFG_PULSE_OUTPUT, REG_CFG_DECEL_TIME_MS - REG_CFG_PULSE_OUTPUT + 1) cfg = PlsrConfiguration( pulse_output=values[0], direction_output=values[1], wait_input=values[2], ext_input=values[3], send_mode=values[4], direction_delay_ms=values[5], direction_logic=values[6], accel_mode=values[7], run_mode=values[8], segment_count=values[9], start_segment=values[10], default_speed_hz=decode_u32(values[11], values[12]), start_speed_hz=decode_u32(values[13], values[14]), end_speed_hz=decode_u32(values[16], values[17]), accel_time_ms=values[18], decel_time_ms=values[19], ) cfg.segments = [self.read_segment(index + 1) for index in range(10)] return cfg def write_configuration(self, config: PlsrConfiguration, save: bool = False) -> None: if self.protocol != MAP_SPEC: raise PlsrError("configuration object is only available in spec protocol") config.validate() values = [config.pulse_output, config.direction_output, config.wait_input, config.ext_input, config.send_mode, config.direction_delay_ms, config.direction_logic, config.accel_mode, config.run_mode, config.segment_count, config.start_segment] values.extend(encode_u32(config.default_speed_hz)) values.extend(encode_u32(config.start_speed_hz)) values.append(0) # 0x100F reserved values.extend(encode_u32(config.end_speed_hz)) values.extend((config.accel_time_ms, config.decel_time_ms)) self.write_registers(REG_CFG_PULSE_OUTPUT, values) for index in range(1, config.segment_count + 1): self.write_segment(index, config.segments[index - 1]) if save: # There is no save register in the revised parameter table. Keep # the argument for GUI compatibility and make the limitation clear. raise PlsrError("configuration written to RAM; add the firmware save command before requesting Flash save") def read_segment(self, index: int) -> SegmentParameters: if self.protocol != MAP_SPEC or not 1 <= index <= 10: raise ValueError("segment index must be 1..10") base = REG_SEGMENT_BASE + (index - 1) * REG_SEGMENT_STRIDE values = self.read_holding(base, 8) return SegmentParameters(decode_u32(values[0], values[1]), decode_i32(values[2], values[3]), values[4], values[5], values[6], values[7]) def write_segment(self, index: int, segment: SegmentParameters) -> None: if self.protocol != MAP_SPEC or not 1 <= index <= 10: raise ValueError("segment index must be 1..10") segment.validate(index) base = REG_SEGMENT_BASE + (index - 1) * REG_SEGMENT_STRIDE self.write_registers(base, [*encode_u32(segment.frequency_hz), *encode_i32(segment.pulses), segment.wait_condition, segment.wait_time_ms, segment.act_time_ms, segment.jump_segment]) def configure_outputs(self, pulse_output: int, direction_output: int, save: bool = False) -> Snapshot: if self.protocol == MAP_LEGACY: if not 0 <= pulse_output <= 7 or not 0 <= direction_output <= 7 or pulse_output == direction_output: raise ValueError("legacy output points must be distinct Q0..Q7") snapshot = self.read_snapshot() if snapshot.state == 2: raise PlsrError("configuration is blocked while motion is Busy") self.write_register(REG_PULSE_OUTPUT, pulse_output) self.write_register(REG_DIRECTION_OUTPUT, direction_output) if save: self.command(COMMAND_SAVE_CONFIG) return self.read_snapshot() if not 0 <= pulse_output <= 3 or not 0 <= direction_output <= 3: raise ValueError("outputs must be Y0..Y3 and Y12..Y15") if self.read_snapshot().state in (1, 2, 3, 4): raise PlsrError("configuration is blocked while motion is active") self.write_register(REG_CFG_PULSE_OUTPUT, pulse_output) self.write_register(REG_CFG_DIRECTION_OUTPUT, direction_output) return self.read_snapshot() def start_motion(self, pulses: int, frequency: int, direction: int = 0, restart: bool = False) -> None: if self.protocol == MAP_LEGACY: if not 1 <= pulses <= 0xFFFFFFFF: raise ValueError("legacy pulses must be 1..4294967295") if not 1 <= frequency <= 1000: raise ValueError("legacy firmware test module supports 1..1000 Hz") if direction not in (0, 1): raise ValueError("direction must be 0 or 1") self.write_register(REG_PULSES_HIGH, (pulses >> 16) & 0xFFFF) self.write_register(REG_PULSES_LOW, pulses & 0xFFFF) self.write_register(REG_FREQUENCY, frequency) self.write_register(REG_DIRECTION, direction) self.command(COMMAND_RESTART if restart else COMMAND_START) return validate_frequency(frequency) if pulses == 0 or not -0x80000000 <= pulses <= 0x7FFFFFFF: raise ValueError("spec pulse count must be non-zero signed 32-bit") if direction not in (0, 1): raise ValueError("direction must be 0 or 1") if direction == 1 and pulses > 0: pulses = -pulses cfg = self.read_configuration() cfg.segments[cfg.start_segment - 1] = SegmentParameters(frequency, pulses) cfg.segment_count = max(cfg.segment_count, cfg.start_segment) cfg.validate() self.write_segment(cfg.start_segment, cfg.segments[cfg.start_segment - 1]) self.command(COMMAND_RESTART if restart else COMMAND_START) def print_snapshot(snapshot: Snapshot) -> None: print(f"state={snapshot.state_name:<14} segment={snapshot.current_segment:<2} " f"frequency={snapshot.current_frequency}Hz total_pulses={snapshot.total_pulses} " f"error={snapshot.last_error}:{snapshot.error_name} outputs={snapshot.pulse_output}/{snapshot.direction_output}") def wait_for_terminal(client: ModbusRtuClient, timeout: float, interval: float = 0.25) -> Snapshot: deadline, previous = time.monotonic() + timeout, None while time.monotonic() < deadline: snapshot = client.read_snapshot() current = (snapshot.state, snapshot.total_pulses, snapshot.last_error) if current != previous: print_snapshot(snapshot) previous = current if snapshot.state in (5, 6, 7): return snapshot time.sleep(interval) try: client.command(COMMAND_STOP) except PlsrError: pass raise PlsrError("motion timeout; stop command was attempted") def require_safe_confirmation(args: argparse.Namespace) -> None: if not args.confirm_safe: raise PlsrError("motion output is blocked; disconnect the mechanism and add --confirm-safe") def offline_self_test() -> None: assert crc16(bytes.fromhex("01 03 00 00 00 0A")) == 0xCDC5 assert read_holding_request(1, 0, 32).hex(" ") == "01 03 00 00 00 20 44 12" assert encode_i32(-1) == (0xFFFF, 0xFFFF) assert decode_i32(0x8000, 0) == -2147483648 request = write_registers_request(1, REG_SEGMENT_BASE, [100, 0, 0xFFFF, 0xFFFF, 0, 0, 0, 0]) assert request[1] == 0x10 and request[6] == 16 response = with_crc(bytes.fromhex("01 03 04 00 05 00 06")) noisy = bytearray(bytes.fromhex("40 00 00") + response) assert extract_response(noisy, 1, 0x03) == response print("PASS CRC16, signed double-word, FC16 and response resynchronization") def add_connection_options(parser: argparse.ArgumentParser) -> None: parser.add_argument("--port", help="serial port, for example COM9") parser.add_argument("--baud", type=int, default=DEFAULT_BAUD_RATE) parser.add_argument("--slave", type=int, default=SLAVE_ADDRESS) parser.add_argument("--timeout", type=float, default=0.6) parser.add_argument("--retries", type=int, default=2) parser.add_argument("--map", choices=(MAP_SPEC, MAP_LEGACY), default=MAP_SPEC, help="spec follows 参数表-2026.xlsx; legacy follows the first firmware image") parser.add_argument("-v", "--verbose", action="store_true") def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="PLSR firmware Modbus RTU test tool") add_connection_options(parser) subs = parser.add_subparsers(dest="command_name", required=True) subs.add_parser("ports") subs.add_parser("self-test") subs.add_parser("mandatory-list") subs.add_parser("status") monitor = subs.add_parser("monitor"); monitor.add_argument("--interval", type=float, default=0.5) run = subs.add_parser("run"); run.add_argument("--pulses", type=int, default=10); run.add_argument("--frequency", type=int, default=2) 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") subs.add_parser("stop"); subs.add_parser("immediate-stop"); subs.add_parser("reset"); subs.add_parser("clear") 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") 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) return parser def run_connected_command(args: argparse.Namespace) -> None: if not args.port: raise PlsrError("--port is required for this command") with ModbusRtuClient(args.port, args.baud, args.slave, args.timeout, args.retries, args.verbose, args.map) as client: if args.command_name == "status": print_snapshot(client.read_snapshot()) elif args.command_name == "monitor": while True: print_snapshot(client.read_snapshot()); time.sleep(max(0.1, args.interval)) elif args.command_name == "run": require_safe_confirmation(args); client.start_motion(args.pulses, args.frequency, int(args.direction == "reverse")) if not args.no_wait and wait_for_terminal(client, args.wait_timeout).state != 5: raise PlsrError("motion did not finish normally") elif args.command_name == "configure": print_snapshot(client.configure_outputs(args.pulse_output, args.direction_output, args.save)) elif args.command_name == "stop": client.command(COMMAND_STOP); print_snapshot(client.read_snapshot()) elif args.command_name == "immediate-stop": client.command(COMMAND_IMMEDIATE_STOP); print_snapshot(client.read_snapshot()) elif args.command_name == "reset": client.command(COMMAND_RESET); print_snapshot(client.read_snapshot()) elif args.command_name == "clear": client.command(COMMAND_CLEAR_COUNT); print_snapshot(client.read_snapshot()) elif args.command_name == "auto-test": 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) if first.state != 5: raise PlsrError("forward test failed") client.start_motion(args.pulses, args.frequency, 1); second = wait_for_terminal(client, args.wait_timeout) if second.state != 5: raise PlsrError("reverse test failed") print("PASS forward/reverse mandatory smoke test") def main(argv: Optional[Sequence[str]] = None) -> int: args = build_parser().parse_args(argv) try: if args.command_name == "self-test": offline_self_test() elif args.command_name == "mandatory-list": print(mandatory_test_checklist()) elif args.command_name == "ports": if list_ports is None: raise PlsrError("pyserial is not installed") for item in sorted(list_ports.comports(), key=lambda p: p.device): print(f"{item.device:<8} {item.description}") else: run_connected_command(args) return 0 except KeyboardInterrupt: return 130 except (OSError, ValueError, PlsrError) as exc: print(f"ERROR: {exc}", file=sys.stderr); return 1 if __name__ == "__main__": raise SystemExit(main())