#!/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()