feat: add relay status query and mesh test cli

This commit is contained in:
十七
2026-11-05 17:38:29 +08:00
parent ebef6a8694
commit aedde75d65
2 changed files with 687 additions and 12 deletions
+289 -12
View File
@@ -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中,
+398
View File
@@ -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()