From aedde75d6510333fd3604c64b50e53aa5b6e7fe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=81=E4=B8=83?= <1415613786@qq.com> Date: Thu, 5 Nov 2026 17:38:29 +0800 Subject: [PATCH] feat: add relay status query and mesh test cli --- STM8_app_asm/src/app_vectors.asm | 301 ++++++++++++++++++++++- tools/mesh_cli_test.py | 398 +++++++++++++++++++++++++++++++ 2 files changed, 687 insertions(+), 12 deletions(-) create mode 100644 tools/mesh_cli_test.py diff --git a/STM8_app_asm/src/app_vectors.asm b/STM8_app_asm/src/app_vectors.asm index 3ebfdea..cedac22 100644 --- a/STM8_app_asm/src/app_vectors.asm +++ b/STM8_app_asm/src/app_vectors.asm @@ -61,6 +61,11 @@ _App_Reset: bset 0x5007, #5 bset 0x5008, #5 bres 0x5009, #5 + ; PA2/PWRC:低电平进入JDY-28M AT指令窗口,高电平恢复Mesh通信。 + bres 0x5000, #2 + bset 0x5002, #2 + bset 0x5003, #2 + bres 0x5004, #2 ; TIM4:16MHz/128/125,目标约1ms更新一次。 mov 0x5340, #0x00 @@ -91,6 +96,13 @@ _App_Reset: ; 防止刚开启接收中断时立即进入一次“伪接收”或把残留字节当作有效数据。 ld a, 0x5230 ld a, 0x5231 + ; 地址查询阶段先使用普通收发模式,避免AT响应进入Mesh环形缓冲区。 + mov 0x5235, #0x0C + ; PWRC拉低后等待约10ms,给JDY-28M预留AT指令窗口建立时间。 + call _App_Delay10ms + call _App_QueryModuleAddress + ; 查询结束后释放PWRC,模块恢复正常Mesh通信。 + bset 0x5000, #2 ; UART接收环形缓冲区使用RAM 0x0020~0x002F,共16字节。 ; 0x0030为写索引,0x0031为读索引,0x0032为溢出计数。 mov 0x0030, #0x00 @@ -139,6 +151,157 @@ _App_MainLoop: mov 0x0036, #0x00 jra _App_MainLoop +;------------------------------------------------------------------------------- +; 查询JDY-28M本机Mesh短地址。 +; 发送AT+MADDR\r\n,解析+MADDR=XXXX。地址保存到0x0046/0x0047, +; 0x004A为有效标志。查询失败时地址标志保持0,不阻止后续Mesh运行。 +;------------------------------------------------------------------------------- +_App_QueryModuleAddress: + mov 0x0048, #0x00 + mov 0x0049, #0x00 + mov 0x004A, #0x00 + mov 0x003E, #0x41 + call _App_SendByte + mov 0x003E, #0x54 + call _App_SendByte + mov 0x003E, #0x2B + call _App_SendByte + mov 0x003E, #0x4D + call _App_SendByte + mov 0x003E, #0x41 + call _App_SendByte + mov 0x003E, #0x44 + call _App_SendByte + mov 0x003E, #0x44 + call _App_SendByte + mov 0x003E, #0x52 + call _App_SendByte + mov 0x003E, #0x0D + call _App_SendByte + mov 0x003E, #0x0A + call _App_SendByte + ldw x, #0xFFFF +_App_AddressWait: + ld a, 0x5230 + and a, #0x20 + jrne _App_AddressRead + decw x + jrne _App_AddressWait + ret +_App_AddressRead: + ld a, 0x5231 + ld 0x003E, a + call _App_AddressParseByte + tnz 0x004A + jrne _App_AddressDone + decw x + jrne _App_AddressWait + ret +_App_AddressDone: + ret + +; 解析单个AT响应字节,状态0~6匹配+MADDR=,状态7接收4位十六进制地址。 +_App_AddressParseByte: + ld a, 0x003E + ld 0x003F, a + ld a, 0x0048 + cp a, #0x00 + jreq _App_AddrPlus + cp a, #0x01 + jreq _App_AddrM + cp a, #0x02 + jreq _App_AddrA + cp a, #0x03 + jreq _App_AddrD1 + cp a, #0x04 + jreq _App_AddrD2 + cp a, #0x05 + jreq _App_AddrR + cp a, #0x06 + jreq _App_AddrEqual + ; 状态7:转换十六进制字符并左移累加。 + call _App_HexNibble + ld 0x003E, a + sll 0x0047 + rlc 0x0046 + sll 0x0047 + rlc 0x0046 + sll 0x0047 + rlc 0x0046 + sll 0x0047 + rlc 0x0046 + ld a, 0x003E + or a, 0x0047 + ld 0x0047, a + inc 0x0049 + ld a, 0x0049 + cp a, #0x04 + jrne _App_AddrReturn + mov 0x004A, #0x01 + ret +_App_AddrPlus: + ld a, 0x003F + cp a, #0x2B + jrne _App_AddrReset + inc 0x0048 + ret +_App_AddrM: + ld a, 0x003F + cp a, #0x4D + jrne _App_AddrReset + inc 0x0048 + ret +_App_AddrA: + ld a, 0x003F + cp a, #0x41 + jrne _App_AddrReset + inc 0x0048 + ret +_App_AddrD1: + ld a, 0x003F + cp a, #0x44 + jrne _App_AddrReset + inc 0x0048 + ret +_App_AddrD2: + ld a, 0x003F + cp a, #0x44 + jrne _App_AddrReset + inc 0x0048 + ret +_App_AddrR: + ld a, 0x003F + cp a, #0x52 + jrne _App_AddrReset + inc 0x0048 + ret +_App_AddrEqual: + ld a, 0x003F + cp a, #0x3D + jrne _App_AddrReset + mov 0x0046, #0x00 + mov 0x0047, #0x00 + mov 0x0049, #0x00 + mov 0x0048, #0x07 + ret +_App_AddrReset: + mov 0x0048, #0x00 +_App_AddrReturn: + ret + +; 将ASCII十六进制字符转换为0~15;非法字符返回0。 +_App_HexNibble: + ld a, 0x003F + sub a, #0x30 + cp a, #0x0A + jrult _App_HexReturn + ld a, 0x003F + sub a, #0x41 + add a, #0x0A + ret +_App_HexReturn: + ret + ;------------------------------------------------------------------------------- ; JDY-28M Mesh外层帧状态机。 ; @@ -167,7 +330,9 @@ _App_MeshParseByte: _App_ParseWaitF1: ld a, 0x0020 cp a, #0xF1 - jrne _App_ParseReturn + jreq _App_F1OK + jpf _App_ParseReturn +_App_F1OK: mov 0x0034, #0x01 ret @@ -180,8 +345,15 @@ _App_ParseWaitDD: _App_ParseWait09: ld a, 0x0020 + ; 当前JDY-28M实测接收帧固定头为F1 DD 08。 + cp a, #0x08 + jreq _App_MeshHeaderOK cp a, #0x09 - jrne _App_ParseReset + jreq _App_MeshHeaderOK + cp a, #0x0A + jreq _App_MeshHeaderOK + jpf _App_ParseReset +_App_MeshHeaderOK: mov 0x0034, #0x03 mov 0x0035, #0x00 ret @@ -255,21 +427,42 @@ _App_ProcessBusiness: ; 先计算CMD、LEN、DATA的CRC,并与业务帧末尾两个字节比较; ; CRC错误时静默丢弃,绝不执行继电器动作。 call _App_CrcBusiness3 + ; CRC结束时0x003F保存CMD+LEN+DATA的总长度,动态定位帧尾CRC。 + ld a, 0x003F + add a, #0x40 + ldw x, #0x0000 + ld xl, a + ld a, (x) + ld 0x003E, a ld a, 0x003D - cp a, 0x0043 + cp a, 0x003E jreq _App_CrcHighOK jpf _App_BusinessReturn _App_CrcHighOK: + incw x + ld a, (x) + ld 0x003E, a ld a, 0x003C - cp a, 0x0044 + cp a, 0x003E jreq _App_CrcLowOK jpf _App_BusinessReturn _App_CrcLowOK: ld a, 0x0040 + cp a, #0x05 + jreq _App_AddressCommand + cp a, #0x02 + jreq _App_StatusCommand cp a, #0x01 - jreq _App_CmdOK + jreq _App_ControlCommand jpf _App_BusinessReturn -_App_CmdOK: + +_App_AddressCommand: + ld a, 0x0041 + cp a, #0x00 + jreq _App_AddressQuery + jpf _App_BusinessReturn + +_App_ControlCommand: ld a, 0x0041 cp a, #0x01 jreq _App_LenOK @@ -284,13 +477,72 @@ _App_LenOK: ; 只有DATA=00或01允许控制继电器。 _App_RelayOff: mov 0x5000, #0x00 + mov 0x0045, #0x81 jra _App_RelayRespond _App_RelayOn: mov 0x5000, #0x02 + mov 0x0045, #0x81 + jra _App_RelayRespond + +; ----------------------------------------------------------------------------- +; 函数/处理分支:_App_StatusCommand +; 功能:处理业务命令CMD=0x02,即主机查询当前继电器状态。 +; +; 输入: +; 0x0041:业务LEN,必须为0,查询命令不携带DATA字段。 +; 0x5000:继电器控制状态寄存器,当前设计中0x02表示吸合,0x00表示断开。 +; +; 输出: +; 返回业务命令CMD=0x82;LEN=1;DATA=0x01表示吸合,0x00表示断开。 +; 返回帧仍由_App_RelayRespond统一计算CRC并封装为JDY-28M发送帧。 +; +; 说明: +; 这里查询的是单片机实际输出的继电器控制状态,而不是触点反馈。 +; 当前硬件没有继电器触点反馈,所以该状态不能证明继电器触点一定 +; 已经机械吸合;触点异常仍由ACS712电流检测和故障上报逻辑判断。 +; ----------------------------------------------------------------------------- +_App_StatusCommand: + ld a, 0x0041 + cp a, #0x00 + jreq _App_StatusQuery + jpf _App_BusinessReturn + +_App_StatusQuery: + mov 0x0045, #0x82 + jra _App_RelayRespond + +_App_AddressQuery: + ; CMD=05、LEN=00:返回CMD=85、LEN=02和本机MADDR。 + ld a, 0x004A + jrne _App_AddressValid + ; 上电查询可能因模块尚未进入AT窗口而失败;收到地址查询时再重试一次。 + bres 0x5000, #2 + call _App_Delay10ms + mov 0x5235, #0x0C + call _App_QueryModuleAddress + bset 0x5000, #2 + mov 0x5235, #0x2C + ld a, 0x004A + jrne _App_AddressValid + ; 地址仍未读取成功时,使用FFFF作为诊断地址继续回复; + ; 主机可据此确认广播命令已经到达设备,但MADDR读取失败。 + mov 0x0046, #0xFF + mov 0x0047, #0xFF +_App_AddressValid: + mov 0x0040, #0x85 + mov 0x0041, #0x02 + ld a, 0x0046 + ld 0x0042, a + ld a, 0x0047 + ld 0x0043, a + jra _App_ResponseAddressCrc _App_RelayRespond: - ; 业务区改写为CMD=81、LEN=1、DATA=当前控制状态。 - mov 0x0040, #0x81 + ; 业务区改写为应答CMD、LEN=1、DATA=当前控制状态。 + ; 控制命令使用0x81,状态查询命令使用0x82;应答命令由调用分支 + ; 预先写入0x0045,避免复制两套完全相同的CRC和发送代码。 + ld a, 0x0045 + ld 0x0040, a mov 0x0041, #0x01 ld a, 0x5000 and a, #0x02 @@ -305,7 +557,16 @@ _App_ResponseCrc: ld 0x0043, a ld a, 0x003C ld 0x0044, a + jra _App_SendResponse +_App_ResponseAddressCrc: + call _App_CrcBusiness3 + ld a, 0x003D + ld 0x0044, a + ld a, 0x003C + ld 0x0045, a ; JDY-28M发送格式:41 54 2B AA A1 | 目标地址 | A0 | 业务区 | 0D 0A。 + ; 目标地址使用外层源地址,即发起查询的主机地址。 +_App_SendResponse: mov 0x003E, #0x41 call _App_SendByte mov 0x003E, #0x54 @@ -324,8 +585,10 @@ _App_ResponseCrc: call _App_SendByte mov 0x003E, #0xA0 call _App_SendByte - ld a, #0x00 - ld 0x003F, a + ld a, 0x0041 + add a, #0x04 + ld 0x0048, a + mov 0x003F, #0x00 _App_SendResponseBusiness: ld a, 0x003F add a, #0x40 @@ -336,7 +599,7 @@ _App_SendResponseBusiness: call _App_SendByte inc 0x003F ld a, 0x003F - cp a, #0x05 + cp a, 0x0048 jrne _App_SendResponseBusiness ; 按JDY-28M Mesh发送协议保留完整帧尾0D 0A。 mov 0x003E, #0x0D @@ -375,7 +638,10 @@ _App_CrcNoPoly: jrne _App_CrcBit inc 0x003F ld a, 0x003F - cp a, #0x03 + ld 0x003E, a + ld a, 0x0041 + add a, #0x02 + cp a, 0x003E jrne _App_CrcByte ret @@ -387,6 +653,17 @@ _App_SendByte: ld 0x5231, a ret +;------------------------------------------------------------------------------- +; 粗略延时约10ms。 +; 主频为16MHz,本延时只用于蓝牙模块PWRC窗口建立,不作为业务定时基准。 +;------------------------------------------------------------------------------- +_App_Delay10ms: + ldw x, #0xFFFF +_App_Delay10msLoop: + decw x + jrne _App_Delay10msLoop + ret + ;------------------------------------------------------------------------------- ; TIM4中断服务函数。 ; 每500次约1ms中断翻转一次PB5,因此目标周期约500ms。计数变量放在RAM中, diff --git a/tools/mesh_cli_test.py b/tools/mesh_cli_test.py new file mode 100644 index 0000000..9be0b9f --- /dev/null +++ b/tools/mesh_cli_test.py @@ -0,0 +1,398 @@ +#!/usr/bin/env python3 +"""JDY-28M Mesh 继电器测试工具。 + +本工具通过电脑串口连接一个处于 Mesh 指令模式的 JDY-28M 模块,提供以下 +测试功能: + +1. 广播查询网络内设备短地址; +2. 将收到的设备短地址整理成编号列表; +3. 按编号选择目标设备; +4. 发送继电器吸合、断开和状态查询命令; +5. 自动计算 CRC-16/MODBUS; +6. 自动解析 JDY-28M 外层接收帧和设备业务应答。 + +业务帧格式: + + CMD | LEN | DATA | CRC16_H | CRC16_L + +主机发送给 JDY-28M 的 Mesh 指令格式: + + 41 54 2B AA A1 | DST_H DST_L | A0 | BUSINESS | 0D 0A + +电脑串口收到的 Mesh 数据格式: + + F1 DD 09 | SRC_H SRC_L | DST_H DST_L | BUSINESS | 0A + +依赖: + + python -m pip install pyserial +""" + +from __future__ import annotations + +import argparse +import time +from dataclasses import dataclass + +import serial +from serial.tools import list_ports + + +# JDY-28M Mesh 主机发送帧的固定部分。 +TX_PREFIX = bytes((0x41, 0x54, 0x2B, 0xAA, 0xA1)) +TX_DATA_FLAG = 0xA0 + +# JDY-28M Mesh 接收帧的固定部分。 +# 根据当前JDY-28M实测,接收帧固定头为F1 DD 0A。 +RX_PREFIXES = ( + bytes((0xF1, 0xDD, 0x08)), + bytes((0xF1, 0xDD, 0x09)), + bytes((0xF1, 0xDD, 0x0A)), +) + +# Mesh 广播目标地址。 +BROADCAST_ADDRESS = 0xFFFF + + +@dataclass(frozen=True) +class MeshFrame: + """一帧已经拆分完成的 JDY-28M Mesh 接收数据。""" + + source: int + destination: int + business: bytes + + +def crc16_modbus(data: bytes) -> int: + """计算 CRC-16/MODBUS,并返回16位整数结果。 + + 算法参数与设备固件一致:多项式为0xA001,初始值为0xFFFF, + 输入和输出均按 MODBUS 反射规则处理。返回值内部按整数保存, + 发送时由调用方拆成高字节、低字节。 + """ + + crc = 0xFFFF + for value in data: + crc ^= value + for _ in range(8): + if crc & 0x0001: + crc = (crc >> 1) ^ 0xA001 + else: + crc >>= 1 + return crc & 0xFFFF + + +def build_business(command: int, data: bytes = b"") -> bytes: + """构造业务帧,并按高字节在前追加CRC16。""" + + if not 0 <= command <= 0xFF: + raise ValueError("命令必须是0~255") + if len(data) > 0xFF: + raise ValueError("业务数据不能超过255字节") + + body = bytes((command, len(data))) + data + crc = crc16_modbus(body) + return body + bytes((crc >> 8, crc & 0xFF)) + + +def build_mesh_command(destination: int, command: int, data: bytes = b"") -> bytes: + """封装一帧发送给JDY-28M的Mesh指令。""" + + if not 0 <= destination <= 0xFFFF: + raise ValueError("目标地址必须是0x0000~0xFFFF") + + business = build_business(command, data) + return ( + TX_PREFIX + + bytes((destination >> 8, destination & 0xFF, TX_DATA_FLAG)) + + business + + bytes((0x0D, 0x0A)) + ) + + +def parse_mesh_frame(buffer: bytearray) -> MeshFrame | None: + """从接收缓存中提取一帧完整 Mesh 数据。 + + 返回值为: + + - 成功提取时返回 MeshFrame; + - 当前缓存还不完整时返回 None,同时保留缓存内容; + - 发现无效前导数据时丢弃无效字节,继续等待下一帧。 + """ + + while True: + matches = [(buffer.find(prefix), prefix) for prefix in RX_PREFIXES] + matches = [(index, prefix) for index, prefix in matches if index >= 0] + if not matches: + # 只保留可能构成半个帧头的末尾字节,防止缓存无限增长。 + # 没有完整帧头时,保留最长帧头长度减1的尾部数据, + # 以便下一次串口读取时拼接出跨读取边界的帧头。 + keep = min(len(buffer), max(len(prefix) for prefix in RX_PREFIXES) - 1) + if keep: + buffer[:] = buffer[-keep:] + else: + buffer.clear() + return None + + prefix_index, _ = min(matches, key=lambda item: item[0]) + if prefix_index: + del buffer[:prefix_index] + + # 固定头3字节 + 源地址2字节 + 目标地址2字节。 + if len(buffer) < 7: + return None + + # 业务数据以0A结束。模块接收侧可能只显示0A,也可能保留0D 0A; + # 这里统一以0A作为接收帧结束字节。 + tail_index = buffer.find(b"\x0A", 7) + if tail_index < 0: + return None + + frame_bytes = bytes(buffer[:tail_index + 1]) + del buffer[:tail_index + 1] + + source = (frame_bytes[3] << 8) | frame_bytes[4] + destination = (frame_bytes[5] << 8) | frame_bytes[6] + business = frame_bytes[7:-1] + # 部分模块接收侧会显示0D 0A,0D属于外层结束符,不属于业务数据。 + if business.endswith(b"\x0D"): + business = business[:-1] + return MeshFrame(source, destination, business) + + +def validate_business(business: bytes) -> tuple[int, bytes] | None: + """校验业务帧长度和CRC,成功时返回命令号及DATA。""" + + if len(business) < 4: + return None + + command = business[0] + data_length = business[1] + if len(business) != data_length + 4: + return None + + body = business[:-2] + received_crc = (business[-2] << 8) | business[-1] + if crc16_modbus(body) != received_crc: + return None + + return command, business[2:2 + data_length] + + +def receive_frames(port: serial.Serial, duration: float) -> list[MeshFrame]: + """在指定时间窗口内读取并解析所有完整 Mesh 帧。""" + + frames: list[MeshFrame] = [] + buffer = bytearray() + deadline = time.monotonic() + duration + + while time.monotonic() < deadline: + waiting = port.in_waiting + if waiting: + received_bytes = port.read(waiting) + # 调试阶段完整打印串口收到的每一段原始数据, + # 包括无法组成完整Mesh帧或格式校验失败的数据。 + print_hex("串口原始接收", received_bytes) + buffer.extend(received_bytes) + while True: + frame = parse_mesh_frame(buffer) + if frame is None: + break + frames.append(frame) + else: + time.sleep(0.005) + + return frames + + +def print_hex(label: str, data: bytes) -> None: + """以空格分隔的十六进制形式打印串口帧。""" + + print(f"{label}: {data.hex(' ').upper()}") + + +def send_command( + port: serial.Serial, + destination: int, + command: int, + data: bytes = b"", +) -> None: + """构造并发送一条Mesh业务命令。""" + + frame = build_mesh_command(destination, command, data) + port.write(frame) + port.flush() + print_hex("已发送", frame) + + +def discover_devices(port: serial.Serial, timeout: float) -> list[int]: + """广播查询设备地址,并返回去重后的设备地址列表。""" + + port.reset_input_buffer() + frame = build_mesh_command(BROADCAST_ADDRESS, 0x05) + port.write(frame) + port.flush() + print_hex("地址查询广播", frame) + print(f"等待设备回复 {timeout:.1f} 秒...") + + addresses: set[int] = set() + for received in receive_frames(port, timeout): + result = validate_business(received.business) + if result is None: + continue + command, data = result + if command == 0x85 and len(data) == 2: + address = (data[0] << 8) | data[1] + addresses.add(address) + print( + f"收到地址应答:来源={received.source:04X}," + f"设备地址={address:04X}" + ) + + return sorted(addresses) + + +def select_device(devices: list[int]) -> int | None: + """显示设备列表并让用户选择一个设备。""" + + if not devices: + print("当前没有已发现的设备,请先执行地址广播查询。") + return None + + print("设备列表:") + for index, address in enumerate(devices, start=1): + print(f" {index}. {address:04X}") + + value = input("请输入设备编号:").strip() + try: + index = int(value) + except ValueError: + print("设备编号无效。") + return None + + if not 1 <= index <= len(devices): + print("设备编号超出范围。") + return None + return devices[index - 1] + + +def wait_for_response(port: serial.Serial, timeout: float, target: int) -> None: + """接收并解析指定设备的业务应答。""" + + for received in receive_frames(port, timeout): + result = validate_business(received.business) + if result is None: + print_hex("收到CRC或长度错误帧", received.business) + continue + + command, data = result + if received.source != target: + print(f"收到其他设备应答:{received.source:04X}") + + if command == 0x81 and len(data) == 1: + state = "吸合" if data[0] else "断开" + print(f"继电器控制成功:{state}(DATA={data[0]:02X})") + elif command == 0x82 and len(data) == 1: + state = "吸合" if data[0] else "断开" + print(f"继电器当前状态:{state}") + elif command == 0xE0 and len(data) == 1: + print(f"设备返回错误:错误码 0x{data[0]:02X}") + else: + print(f"收到应答 CMD=0x{command:02X}, DATA={data.hex(' ').upper()}") + return + + print("等待设备应答超时。") + + +def run_menu(port: serial.Serial, response_timeout: float) -> None: + """运行交互式菜单。""" + + devices: list[int] = [] + + while True: + print("\n===== STM8 Mesh 继电器测试工具 =====") + print("1. 广播查询设备地址") + print("2. 显示设备列表") + print("3. 选择设备并吸合继电器") + print("4. 选择设备并断开继电器") + print("5. 选择设备并查询继电器状态") + print("0. 退出") + + choice = input("请选择:").strip() + if choice == "0": + return + if choice == "1": + devices = discover_devices(port, response_timeout) + print("发现设备:", ", ".join(f"{value:04X}" for value in devices) or "无") + continue + if choice == "2": + if devices: + print("、".join(f"{index}. {value:04X}" for index, value in enumerate(devices, 1))) + else: + print("设备列表为空。") + continue + if choice in {"3", "4", "5"}: + target = select_device(devices) + if target is None: + continue + + if choice == "3": + send_command(port, target, 0x01, bytes((0x01,))) + elif choice == "4": + send_command(port, target, 0x01, bytes((0x00,))) + else: + send_command(port, target, 0x02) + wait_for_response(port, response_timeout, target) + continue + + print("未知选项。") + + +def select_serial_port() -> str: + """扫描电脑可用串口并让用户选择一个串口。 + + 同一个串口在不同操作系统上的描述字段可能不同,因此同时显示设备路径、 + 设备名称、描述和硬件信息,方便用户识别USB转串口适配器。 + """ + + ports = sorted(list_ports.comports(), key=lambda item: item.device) + if not ports: + raise RuntimeError("没有扫描到可用串口,请检查USB转串口设备和驱动。") + + print("可用串口:") + for index, item in enumerate(ports, start=1): + description = item.description or "无描述" + manufacturer = item.manufacturer or "未知厂商" + print(f" {index}. {item.device} | {description} | {manufacturer}") + + while True: + value = input("请选择串口编号:").strip() + try: + index = int(value) + except ValueError: + print("请输入有效的数字编号。") + continue + + if 1 <= index <= len(ports): + return ports[index - 1].device + print("串口编号超出范围,请重新选择。") + + +def main() -> None: + """解析命令行参数并启动测试菜单。""" + + parser = argparse.ArgumentParser(description="JDY-28M Mesh继电器测试工具") + parser.add_argument("--port", help="直接指定电脑串口;不指定时启动后扫描并选择") + parser.add_argument("--baudrate", type=int, default=9600, help="串口波特率,默认9600") + parser.add_argument("--timeout", type=float, default=0.2, help="串口读取超时,默认0.2秒") + parser.add_argument("--response-timeout", type=float, default=3.0, help="设备应答等待时间,默认10秒") + args = parser.parse_args() + + selected_port = args.port or select_serial_port() + with serial.Serial(selected_port, args.baudrate, timeout=args.timeout) as port: + print(f"已打开串口:{selected_port},波特率:{args.baudrate}") + run_menu(port, args.response_timeout) + + +if __name__ == "__main__": + main()