- synchronize STM8 application and external flash layout updates\n- add assembly bootloader and assembly application test projects\n- add UART1 interrupt receive ring buffer\n- parse JDY-28M Mesh outer frames and source addresses\n- add CRC-16/MODBUS validation\n- implement relay control command 0x01 and response 0x81\n- support unicast and broadcast relay control frames\n- update host protocol, OTA documents, and serial tools
410 lines
16 KiB
Python
410 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
"""通过电脑串口测试STM8 Mesh继电器的主程序OTA功能。
|
||
|
||
发送流程:
|
||
0x08:发送固件长度和版本
|
||
0x09:逐包发送固件数据,并等待设备返回下一期待偏移
|
||
0x0A:发送完整固件CRC16
|
||
0x0B:请求设备分片校验W25Q80BV中的固件
|
||
0x0D:设备复位并升级后主动上报升级结果
|
||
|
||
当前协议约定:
|
||
- JDY-28M发送外层:41 54 2B AA A1 | DST_H DST_L | A0 | BUSINESS | 0D 0A
|
||
- 业务帧:CMD | LEN | DATA | CRC16_H CRC16_L
|
||
- CRC算法:CRC-16/MODBUS
|
||
- DATA中的多字节数值和CRC均为高字节在前
|
||
- CMD=0x09每帧最多携带2字节固件数据
|
||
|
||
依赖:
|
||
python3 -m pip install pyserial
|
||
|
||
示例:
|
||
python3 tools/ota_serial_test.py \
|
||
--port /dev/cu.usbserial-0001 \
|
||
--firmware build/mesh_relay.bin \
|
||
--source 0001 \
|
||
--target 000C
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import re
|
||
import struct
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
try:
|
||
import serial
|
||
except ImportError as exc:
|
||
raise SystemExit(
|
||
"缺少pyserial,请先执行:python3 -m pip install pyserial"
|
||
) from exc
|
||
|
||
|
||
# JDY-28M Mesh发送外层固定字段。
|
||
MESH_TX_PREFIX = bytes.fromhex("41 54 2B AA A1")
|
||
MESH_DATA_FLAG = 0xA0
|
||
MESH_TAIL = bytes.fromhex("0A")
|
||
|
||
# JDY-28M接收数据时,串口侧看到的固定前缀。
|
||
MESH_RX_PREFIX = bytes.fromhex("F1 DD 09")
|
||
|
||
# OTA命令定义。
|
||
CMD_OTA_START = 0x08
|
||
CMD_OTA_DATA = 0x09
|
||
CMD_OTA_FINISH = 0x0A
|
||
CMD_OTA_VERIFY = 0x0B
|
||
CMD_OTA_RESULT_REPORT = 0x0D
|
||
CMD_OTA_RESULT_ACK = 0x8D
|
||
CMD_FIRMWARE_ANNOUNCE = 0x10
|
||
CMD_FIRMWARE_ANNOUNCE_RESPONSE = 0x90
|
||
|
||
# 协议和目标MCU的限制。
|
||
MAX_FIRMWARE_SIZE = 0x2000
|
||
OTA_CHUNK_SIZE = 2
|
||
|
||
|
||
def crc16_modbus(data: bytes) -> int:
|
||
"""计算CRC-16/MODBUS。
|
||
|
||
CRC计算范围不包含Mesh外层帧头、目标地址、A0和帧尾,只包含业务帧:
|
||
SRC、CMD、LEN和DATA。
|
||
"""
|
||
|
||
crc = 0xFFFF
|
||
for value in data:
|
||
crc ^= value
|
||
for _ in range(8):
|
||
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
|
||
return crc & 0xFFFF
|
||
|
||
|
||
def parse_hex_address(value: str, name: str) -> int:
|
||
"""解析两字节十六进制Mesh短地址。"""
|
||
|
||
try:
|
||
address = int(value, 16)
|
||
except ValueError as exc:
|
||
raise argparse.ArgumentTypeError(f"{name}不是有效的十六进制地址:{value}") from exc
|
||
if not 0 <= address <= 0xFFFF:
|
||
raise argparse.ArgumentTypeError(f"{name}必须在0000~FFFF范围内")
|
||
return address
|
||
|
||
|
||
def read_firmware_version(header_path: Path) -> int:
|
||
"""从主程序app_config.h读取FIRMWARE_VERSION。"""
|
||
|
||
content = header_path.read_text(encoding="utf-8")
|
||
match = re.search(
|
||
r"^\s*#define\s+FIRMWARE_VERSION\s+((?:0[xX][0-9A-Fa-f]+)|(?:[0-9]+))",
|
||
content,
|
||
flags=re.MULTILINE,
|
||
)
|
||
if match is None:
|
||
raise ValueError(f"在{header_path}中找不到FIRMWARE_VERSION")
|
||
version = int(match.group(1), 0)
|
||
if not 0 <= version <= 0xFFFF:
|
||
raise ValueError(f"固件版本超出16位范围:{version}")
|
||
return version
|
||
|
||
|
||
def make_business_frame(source: int, command: int, data: bytes) -> bytes:
|
||
"""生成业务帧,末尾CRC16按高字节在前发送。"""
|
||
|
||
if len(data) > 4:
|
||
raise ValueError("当前JDY-28M外层20字节限制下,业务DATA不能超过4字节")
|
||
# JDY-28M外层已经携带发送方和接收方地址,业务帧不再重复嵌入source。
|
||
body = struct.pack(">BB", command, len(data)) + data
|
||
crc = crc16_modbus(body)
|
||
return body + struct.pack(">H", crc)
|
||
|
||
|
||
def make_mesh_frame(target: int, business: bytes) -> bytes:
|
||
"""封装JDY-28M Mesh发送帧。"""
|
||
|
||
return (
|
||
MESH_TX_PREFIX
|
||
+ struct.pack(">H", target)
|
||
+ bytes([MESH_DATA_FLAG])
|
||
+ business
|
||
+ MESH_TAIL
|
||
)
|
||
|
||
|
||
def send_command(ser: serial.Serial, source: int, target: int,
|
||
command: int, data: bytes) -> None:
|
||
"""通过串口发送一条完整Mesh业务命令。"""
|
||
|
||
frame = make_mesh_frame(target, make_business_frame(source, command, data))
|
||
ser.write(frame)
|
||
ser.flush()
|
||
|
||
|
||
def parse_received_business(frame: bytes) -> tuple[int, int, bytes] | None:
|
||
"""从JDY-28M接收外层帧中提取业务命令并校验CRC。
|
||
|
||
接收格式为:
|
||
F1 DD 09 | SRC地址2字节 | DST地址2字节 | BUSINESS | 0A
|
||
|
||
发送方和接收方地址由JDY-28M外层提供,业务帧不再重复携带地址。
|
||
"""
|
||
|
||
if len(frame) < len(MESH_RX_PREFIX) + 4 + 4 + 1:
|
||
return None
|
||
if not frame.startswith(MESH_RX_PREFIX) or not frame.endswith(MESH_TAIL):
|
||
return None
|
||
|
||
source = struct.unpack(">H", frame[3:5])[0]
|
||
business = frame[len(MESH_RX_PREFIX) + 4:-1]
|
||
if len(business) < 4:
|
||
return None
|
||
command, length = struct.unpack(">BB", business[:2])
|
||
if len(business) != 2 + length + 2 or length > 4:
|
||
return None
|
||
body = business[:2 + length]
|
||
received_crc = struct.unpack(">H", business[2 + length:])[0]
|
||
if crc16_modbus(body) != received_crc:
|
||
return None
|
||
return source, command, business[2:2 + length]
|
||
|
||
|
||
def receive_response(ser: serial.Serial, timeout: float) -> tuple[int, int, bytes]:
|
||
"""等待并解析一条合法的JDY-28M业务响应。"""
|
||
|
||
deadline = time.monotonic() + timeout
|
||
buffer = bytearray()
|
||
while time.monotonic() < deadline:
|
||
waiting = ser.in_waiting
|
||
if waiting:
|
||
buffer.extend(ser.read(waiting))
|
||
else:
|
||
time.sleep(0.005)
|
||
|
||
while True:
|
||
start = buffer.find(MESH_RX_PREFIX)
|
||
if start < 0:
|
||
# 保留可能是固定前缀开头的最后几个字节,避免跨次读取丢帧。
|
||
keep = min(len(buffer), len(MESH_RX_PREFIX) - 1)
|
||
if keep:
|
||
del buffer[:-keep]
|
||
else:
|
||
buffer.clear()
|
||
break
|
||
if start:
|
||
del buffer[:start]
|
||
if len(buffer) < len(MESH_RX_PREFIX) + 4 + 4 + 1:
|
||
break
|
||
|
||
data_length = buffer[len(MESH_RX_PREFIX) + 4 + 1]
|
||
total = len(MESH_RX_PREFIX) + 4 + 2 + data_length + 2 + 1
|
||
if data_length > 4:
|
||
del buffer[0]
|
||
continue
|
||
if len(buffer) < total:
|
||
break
|
||
|
||
candidate = bytes(buffer[:total])
|
||
del buffer[:total]
|
||
parsed = parse_received_business(candidate)
|
||
if parsed is not None:
|
||
return parsed
|
||
|
||
raise TimeoutError(f"等待设备响应超时({timeout:.1f}秒)")
|
||
|
||
|
||
def wait_command_response(ser: serial.Serial, expected_command: int,
|
||
timeout: float) -> bytes:
|
||
"""等待指定应答命令,忽略上线通知等无关业务帧。"""
|
||
|
||
deadline = time.monotonic() + timeout
|
||
while time.monotonic() < deadline:
|
||
source, command, data = receive_response(
|
||
ser, max(0.1, deadline - time.monotonic())
|
||
)
|
||
print(f"收到响应:SRC={source:04X} CMD={command:02X} DATA={data.hex(' ').upper()}")
|
||
if command == expected_command:
|
||
return data
|
||
raise TimeoutError(f"未收到期望响应CMD={expected_command:02X}")
|
||
|
||
|
||
def wait_upgrade_result(ser: serial.Serial, timeout: float) -> tuple[int, int]:
|
||
"""等待设备复位后主动上报CMD=0x0D升级结果。
|
||
|
||
0x8B只表示外部Flash固件校验成功。设备随后进入Bootloader搬运固件,
|
||
重启主程序并在蓝牙初始化完成后广播0x0D。因此这里除了结果码,还要
|
||
保留JDY-28M外层提供的SRC地址,以便后续发送0x8D确认。
|
||
|
||
@return (设备短地址, 升级结果码)。
|
||
@raises TimeoutError 在指定时间内未收到合法0x0D。
|
||
"""
|
||
deadline = time.monotonic() + timeout
|
||
while time.monotonic() < deadline:
|
||
try:
|
||
source, command, data = receive_response(
|
||
ser, max(0.1, deadline - time.monotonic())
|
||
)
|
||
except TimeoutError:
|
||
break
|
||
print(f"收到升级结果:SRC={source:04X} CMD={command:02X} "
|
||
f"DATA={data.hex(' ').upper()}")
|
||
if command == CMD_OTA_RESULT_REPORT and len(data) == 1:
|
||
return source, data[0]
|
||
raise TimeoutError(f"等待设备升级结果超时({timeout:.1f}秒)")
|
||
|
||
|
||
def send_ota(args: argparse.Namespace) -> None:
|
||
"""执行完整OTA升级测试。"""
|
||
|
||
firmware = Path(args.firmware).read_bytes()
|
||
if not firmware:
|
||
raise ValueError("固件文件为空")
|
||
if len(firmware) > MAX_FIRMWARE_SIZE:
|
||
raise ValueError(f"固件大小{len(firmware)}超过8KB限制")
|
||
|
||
version_header = Path(args.version_header)
|
||
version = args.version if args.version is not None else read_firmware_version(version_header)
|
||
crc = crc16_modbus(firmware)
|
||
print(f"固件:{args.firmware}")
|
||
print(f"长度:{len(firmware)} 字节,版本:0x{version:04X},CRC16:0x{crc:04X}")
|
||
|
||
with serial.Serial(args.port, args.baudrate, timeout=0) as ser:
|
||
# 进入OTA:长度和版本均为高字节在前。
|
||
send_command(ser, args.source, args.target, CMD_OTA_START,
|
||
struct.pack(">HH", len(firmware), version))
|
||
status = wait_command_response(ser, 0x88, args.timeout)
|
||
if status != b"\x00":
|
||
raise RuntimeError(f"设备拒绝进入OTA,状态={status.hex(' ').upper()}")
|
||
|
||
offset = 0
|
||
while offset < len(firmware):
|
||
payload = firmware[offset:offset + OTA_CHUNK_SIZE]
|
||
data = struct.pack(">H", offset) + payload
|
||
send_command(ser, args.source, args.target, CMD_OTA_DATA, data)
|
||
response = wait_command_response(ser, 0x89, args.timeout)
|
||
if len(response) != 3 or response[0] != 0:
|
||
raise RuntimeError(f"分包写入失败,响应={response.hex(' ').upper()}")
|
||
expected_offset = struct.unpack(">H", response[1:3])[0]
|
||
expected_local = offset + len(payload)
|
||
if expected_offset != expected_local:
|
||
raise RuntimeError(
|
||
f"设备期待偏移0x{expected_offset:04X},本地下一偏移为0x{expected_local:04X}"
|
||
)
|
||
offset = expected_offset
|
||
print(f"OTA分包进度:{offset}/{len(firmware)}", end="\r", flush=True)
|
||
print()
|
||
|
||
# 所有分包均已确认后,再发送完整固件CRC16。
|
||
send_command(ser, args.source, args.target, CMD_OTA_FINISH,
|
||
struct.pack(">H", crc))
|
||
status = wait_command_response(ser, 0x8A, args.timeout)
|
||
if status != b"\x00":
|
||
raise RuntimeError(f"设备拒绝固件CRC,状态={status.hex(' ').upper()}")
|
||
|
||
# 请求设备后台分片读取W25Q80BV并计算完整CRC。
|
||
send_command(ser, args.source, args.target, CMD_OTA_VERIFY, b"")
|
||
status = wait_command_response(ser, 0x8B, args.verify_timeout)
|
||
if status != b"\x00":
|
||
raise RuntimeError(f"设备固件校验失败,状态={status.hex(' ').upper()}")
|
||
|
||
# 0x8B只表示外部Flash中的固件校验成功。设备随后自动复位,
|
||
# 由Bootloader搬运主程序;这里继续等待主程序启动后的0x0D上报。
|
||
device_address, upgrade_result = wait_upgrade_result(
|
||
ser, args.result_timeout
|
||
)
|
||
if upgrade_result != 0x01:
|
||
raise RuntimeError(
|
||
f"设备升级失败:设备地址={device_address:04X},"
|
||
f"结果码=0x{upgrade_result:02X}"
|
||
)
|
||
|
||
# 0x01表示Bootloader搬运并校验成功。回复0x8D后,设备清除待上报
|
||
# 结果,避免后续每次启动都重复上报同一条升级结果。
|
||
send_command(ser, args.source, device_address,
|
||
CMD_OTA_RESULT_ACK, bytes([upgrade_result]))
|
||
|
||
print("OTA升级成功:设备已复位、Bootloader已搬运主程序,并完成结果确认。")
|
||
|
||
|
||
def discover_upgrade_devices(args: argparse.Namespace) -> None:
|
||
"""广播新固件信息并收集需要升级的设备列表。"""
|
||
|
||
firmware = Path(args.firmware).read_bytes()
|
||
if not firmware or len(firmware) > MAX_FIRMWARE_SIZE:
|
||
raise ValueError("固件为空或超过8KB限制")
|
||
version = args.version if args.version is not None else read_firmware_version(
|
||
Path(args.version_header)
|
||
)
|
||
|
||
print(f"广播固件版本:0x{version:04X},长度:{len(firmware)}字节")
|
||
devices: dict[int, tuple[int, int]] = {}
|
||
with serial.Serial(args.port, args.baudrate, timeout=0) as ser:
|
||
# 0x10使用广播地址FFFF,仅通知版本和长度,不会直接启动OTA。
|
||
announce = struct.pack(">HH", version, len(firmware))
|
||
send_command(ser, args.source, 0xFFFF,
|
||
CMD_FIRMWARE_ANNOUNCE, announce)
|
||
|
||
deadline = time.monotonic() + args.discovery_timeout
|
||
while time.monotonic() < deadline:
|
||
try:
|
||
source, command, data = receive_response(
|
||
ser, max(0.1, deadline - time.monotonic())
|
||
)
|
||
except TimeoutError:
|
||
break
|
||
if command != CMD_FIRMWARE_ANNOUNCE_RESPONSE:
|
||
continue
|
||
if len(data) != 4:
|
||
continue
|
||
current_version, announced_version = struct.unpack(">HH", data)
|
||
devices[source] = (current_version, announced_version)
|
||
print(
|
||
f"发现待升级设备:地址={source:04X},"
|
||
f"当前版本=0x{current_version:04X},"
|
||
f"目标版本=0x{announced_version:04X}"
|
||
)
|
||
|
||
print(f"共发现{len(devices)}台需要升级的设备。")
|
||
print("人工确认后,请使用--target 设备短地址执行正式OTA。")
|
||
|
||
|
||
def main() -> int:
|
||
"""解析命令行参数并启动OTA测试。"""
|
||
|
||
parser = argparse.ArgumentParser(description="STM8 Mesh继电器串口OTA测试工具")
|
||
parser.add_argument("--port", required=True, help="电脑串口,例如COM3或/dev/cu.usbserial-xxx")
|
||
parser.add_argument("--firmware", required=True, help="主程序裸BIN文件")
|
||
parser.add_argument("--source", default="0001", type=lambda value: parse_hex_address(value, "source"),
|
||
help="主机Mesh短地址,默认0001")
|
||
parser.add_argument("--target", default="FFFF", type=lambda value: parse_hex_address(value, "target"),
|
||
help="设备短地址,FFFF表示广播,默认FFFF")
|
||
parser.add_argument("--baudrate", type=int, default=9600, help="UART波特率,默认9600")
|
||
parser.add_argument("--timeout", type=float, default=3.0, help="普通应答超时秒数,默认3秒")
|
||
parser.add_argument("--verify-timeout", type=float, default=30.0, help="CRC校验应答超时秒数,默认30秒")
|
||
parser.add_argument("--result-timeout", type=float, default=20.0,
|
||
help="等待设备复位后上报升级结果的秒数,默认20秒")
|
||
parser.add_argument("--discover", action="store_true",
|
||
help="只广播固件信息并收集待升级设备,不执行OTA")
|
||
parser.add_argument("--discovery-timeout", type=float, default=3.0,
|
||
help="设备发现等待时间,默认3秒")
|
||
parser.add_argument("--version-header", default="include/app_config.h",
|
||
help="版本配置头文件,默认include/app_config.h")
|
||
parser.add_argument("--version", type=lambda value: int(value, 0), default=None,
|
||
help="可选,手工指定版本;不指定时自动读取FIRMWARE_VERSION")
|
||
args = parser.parse_args()
|
||
|
||
try:
|
||
if args.discover:
|
||
discover_upgrade_devices(args)
|
||
else:
|
||
send_ota(args)
|
||
except (OSError, serial.SerialException, TimeoutError, ValueError, RuntimeError) as exc:
|
||
print(f"OTA失败:{exc}", file=sys.stderr)
|
||
return 1
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|