feat(DBNMQTTool): OTA 页签 + 协议单测 (MQTT V1.08 工具先行)
- protocol.py: OTA 模块 — 命令常量/ota_crc32(ISO-HDLC)/ota_split_bin(256B×96KB)/ data_ota_* 构建器/状态·阶段·错误码描述 - main.py: OTA 页签 — bin 选择(≤96KB+CRC32 显示)/版本·force·slot 参数/ 下载(OtaDownloadThread: begin→data×N→end, 断点续传·缺片重定位·CRC 重发)/ 刷写(二次确认)/中止/状态查询; 进度条 + ota_report/ota_status 实时显示 - tests/test_ota_protocol.py: 22 断言 — CRC32 标准向量/分片边界/构建器/ OtaDeviceSim 设备状态机(顺序·幂等·乱序·单片CRC·全镜像复核·续传)/完整下载流 - devlog 置顶条目
This commit is contained in:
@@ -9,6 +9,7 @@ DLD960 IoT MQTT 协议定义
|
||||
import json
|
||||
import time
|
||||
import struct
|
||||
import zlib
|
||||
from typing import Optional, Any
|
||||
from dataclasses import dataclass, field, asdict
|
||||
|
||||
@@ -273,6 +274,94 @@ def data_log_clear(stream: str = STREAM_EVENT) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# OTA 远程升级 (V1.08: Loop MCU 先存后刷, ROADMAP P1.4 ①)
|
||||
# 协议依据《DLD960_IoT_MQTT协议.md》§4.19~4.24 / §5.5
|
||||
# ============================================================
|
||||
|
||||
CMD_OTA_BEGIN = "ota_begin"
|
||||
CMD_OTA_DATA = "ota_data"
|
||||
CMD_OTA_END = "ota_end"
|
||||
CMD_OTA_ABORT = "ota_abort"
|
||||
CMD_OTA_FLASH = "ota_flash"
|
||||
CMD_OTA_STATUS = "ota_status"
|
||||
CMD_OTA_REPORT = "ota_report" # dev→srv 主动上报
|
||||
|
||||
OTA_TARGET_LOOP = "loop"
|
||||
OTA_CHUNK_SIZE = 256 # 单片 256B(协议常量, 不接受协商)
|
||||
OTA_MAX_SIZE = 96 * 1024 # 镜像上限 96KB (Slot 100KB - 4KB 边界余量)
|
||||
OTA_SLOT_A = "a"
|
||||
OTA_SLOT_B = "b"
|
||||
|
||||
OTA_STATE_DESC = {
|
||||
"idle": "空闲",
|
||||
"downloading": "下载中",
|
||||
"ready": "校验通过(可刷写)",
|
||||
"flashing": "刷写中",
|
||||
"flash_failed": "刷写失败",
|
||||
"aborted": "已中止",
|
||||
}
|
||||
|
||||
OTA_STAGE_DESC = {
|
||||
"begin": "会话开启",
|
||||
"downloading": "分片落盘",
|
||||
"ready": "校验通过",
|
||||
"flashing": "刷写中",
|
||||
"done": "刷写成功(Loop 已重启)",
|
||||
"failed": "刷写失败",
|
||||
}
|
||||
|
||||
OTA_ERROR_DESC = {
|
||||
0x1001: "启动帧无响应",
|
||||
0x1002: "地址帧错误",
|
||||
0x1003: "数据块 ACK 超限",
|
||||
0x1004: "全镜像校验失败",
|
||||
0x1005: "安全窗口拒绝后强制失败",
|
||||
}
|
||||
|
||||
|
||||
def ota_crc32(data: bytes) -> int:
|
||||
"""CRC-32/ISO-HDLC (协议 §4.19.1; zlib.crc32 即此算法, 设备查表法一致)"""
|
||||
return zlib.crc32(data) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def ota_split_bin(data: bytes, chunk_size: int = OTA_CHUNK_SIZE) -> list[tuple[int, int, str]]:
|
||||
"""bin → 分片列表 [(offset, crc32, hex_str), ...] (单片 256B, hex 512 字符)"""
|
||||
if len(data) > OTA_MAX_SIZE:
|
||||
raise ValueError(f"镜像 {len(data)}B 超上限 {OTA_MAX_SIZE}B (96KB)")
|
||||
return [(off, ota_crc32(data[off:off + chunk_size]), data[off:off + chunk_size].hex())
|
||||
for off in range(0, len(data), chunk_size)]
|
||||
|
||||
|
||||
def data_ota_begin(size: int, crc32: int, version: str = "",
|
||||
target: str = OTA_TARGET_LOOP, force: bool = False) -> dict:
|
||||
"""ota_begin 请求 data (开启会话 / 断点续传定位)"""
|
||||
return {"target": target, "size": size, "crc32": crc32,
|
||||
"version": version, "force": force}
|
||||
|
||||
|
||||
def data_ota_data(offset: int, crc32: int, data_hex: str,
|
||||
target: str = OTA_TARGET_LOOP) -> dict:
|
||||
"""ota_data 请求 data (分片下发, offset 256 对齐)"""
|
||||
return {"target": target, "offset": offset, "crc32": crc32, "data": data_hex}
|
||||
|
||||
|
||||
def data_ota_end(crc32: int, target: str = OTA_TARGET_LOOP) -> dict:
|
||||
"""ota_end 请求 data (结束下载, 全镜像 CRC32 复核)"""
|
||||
return {"target": target, "crc32": crc32}
|
||||
|
||||
|
||||
def data_ota_abort(target: str = OTA_TARGET_LOOP) -> dict:
|
||||
"""ota_abort 请求 data (中止会话, 释放暂存)"""
|
||||
return {"target": target}
|
||||
|
||||
|
||||
def data_ota_flash(slot: str = OTA_SLOT_A, force: bool = False,
|
||||
target: str = OTA_TARGET_LOOP) -> dict:
|
||||
"""ota_flash 请求 data (触发本地 ISP 刷写, 仅 ready 态)"""
|
||||
return {"target": target, "slot": slot, "force": force}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 解析设备上报
|
||||
# ============================================================
|
||||
|
||||
@@ -6,6 +6,31 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-20 — OTA 页签:Loop 远程 OTA 分片下载(协议 V1.08 工具先行)
|
||||
|
||||
### 背景
|
||||
|
||||
ROADMAP P1.4 ①:Loop MCU (AT32F421) 远程 OTA,先存后刷。协议设计稿 V1.01 已定(`docs/DLD960_MQTT_OTA协议.md`),并入 MQTT 协议 V1.08。按"工具先行,固件后到"惯例,先在 DBNMQTTool 实现 OTA 页签 + 协议单测,验证协议自洽后再动固件。
|
||||
|
||||
### 变更
|
||||
|
||||
- **protocol.py**:新增 OTA 模块——`CMD_OTA_*` 命令常量、`ota_crc32`(CRC-32/ISO-HDLC = zlib.crc32,标准向量 0xCBF43926)、`ota_split_bin`(256B/片 hex,96KB 上限)、`data_ota_begin/data/end/abort/flash` 构建器、状态/阶段/错误码中文描述
|
||||
- **main.py**:新增 **OTA 页签**——固件 bin 选择(≤96KB 校验 + 全镜像 CRC32 显示)、目标版本/force/slot 参数、①下载(begin→data×N→end,`OtaDownloadThread` QThread 后台 + 响应队列,断点续传/缺片重定位/CRC 错重发)②刷写(ota_flash 二次确认)③中止 ④查询状态;进度条 + 状态日志(ota_report/ota_status 实时显示)
|
||||
- **tests/test_ota_protocol.py**:22 断言——CRC32 标准向量、分片边界(1B/256B/257B/超限)、构建器字段、**OtaDeviceSim 设备侧状态机**(顺序/幂等/乱序拒绝/单片 CRC/全镜像复核/断点续传)+ 完整下载流(正常/缺片补发)
|
||||
|
||||
### 验证
|
||||
|
||||
- `venv/bin/python -m unittest tests.test_ota_protocol`:22 全过
|
||||
- offscreen MainWindow 冒烟:OTA tab 构建正常、控件齐全、512B bin → 2 片 CRC 正确
|
||||
- 协议规则自洽性验证:设备模拟器按文档规则(offset==received 顺序片 / offset<received 幂等 / offset>received 拒收 code=1)与工具分片流程对跑,断点续传与缺片重定位全通
|
||||
|
||||
### 已知约束
|
||||
|
||||
- 下载线程每片停等 ACK(~512 片 × RTT);量产平台可改窗口并发,协议层无需变更
|
||||
- 固件未实现 ota_* 前,工具下发会收 `code=4 unsupported`——能力探测见协议 §4.19
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-18 — log_query 响应改 hex 原始字节解析展示(协议 V1.07 修订)
|
||||
|
||||
### 背景
|
||||
|
||||
+325
-1
@@ -7,6 +7,8 @@ DBN MQTT Tool — DLD960 IoT MQTT 设备管理工具
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import queue
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@@ -18,8 +20,9 @@ from PySide6.QtWidgets import (
|
||||
QTabWidget, QTextEdit, QGroupBox, QGridLayout, QCheckBox,
|
||||
QSplitter, QMessageBox, QHeaderView, QFrame,
|
||||
QComboBox, QSpinBox, QPlainTextEdit,
|
||||
QFileDialog, QProgressBar,
|
||||
)
|
||||
from PySide6.QtCore import Qt, QTimer, Signal, Slot
|
||||
from PySide6.QtCore import Qt, QTimer, Signal, Slot, QThread
|
||||
from PySide6.QtGui import QFont, QColor, QTextCursor
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
@@ -39,8 +42,128 @@ from dbn_mqtt_tool.protocol import (
|
||||
data_pwd_verify, data_pwd_set, data_report_config, data_log_query, data_log_clear,
|
||||
build_request, next_msg_id,
|
||||
topic_down, topic_up,
|
||||
# V1.08 OTA
|
||||
CMD_OTA_BEGIN, CMD_OTA_DATA, CMD_OTA_END, CMD_OTA_ABORT,
|
||||
CMD_OTA_FLASH, CMD_OTA_STATUS, CMD_OTA_REPORT,
|
||||
OTA_TARGET_LOOP, OTA_CHUNK_SIZE, OTA_SLOT_A, OTA_SLOT_B,
|
||||
OTA_STATE_DESC, OTA_STAGE_DESC, OTA_ERROR_DESC,
|
||||
data_ota_begin, data_ota_data, data_ota_end, data_ota_abort, data_ota_flash,
|
||||
ota_crc32, ota_split_bin,
|
||||
)
|
||||
|
||||
# OTA 相关命令集合 (用于响应分发)
|
||||
OTA_CMD_SET = {CMD_OTA_BEGIN, CMD_OTA_DATA, CMD_OTA_END, CMD_OTA_ABORT,
|
||||
CMD_OTA_FLASH, CMD_OTA_STATUS}
|
||||
|
||||
|
||||
class OtaDownloadThread(QThread):
|
||||
"""OTA 分片下载线程 (协议 V1.08): ota_begin → ota_data×N → ota_end
|
||||
断点续传: ota_begin 响应 offset 为设备已接收字节数, 从此处续传
|
||||
重定位: ota_data code=1 时按 data.offset 调整 (缺片/乱序/CRC)
|
||||
"""
|
||||
progress = Signal(int, int) # sent, total
|
||||
log = Signal(str)
|
||||
finished_ok = Signal(str)
|
||||
finished_err = Signal(str)
|
||||
|
||||
def __init__(self, mqtt_client: MqttClient, dev_serial: str, bin_data: bytes,
|
||||
version: str, force: bool, resp_queue: "queue.Queue"):
|
||||
super().__init__()
|
||||
self._mqtt = mqtt_client
|
||||
self._sn = dev_serial
|
||||
self._bin = bin_data
|
||||
self._version = version
|
||||
self._force = force
|
||||
self._resp_queue = resp_queue
|
||||
self._stop = False
|
||||
|
||||
def stop(self):
|
||||
self._stop = True
|
||||
|
||||
def _send(self, cmd: str, data: dict) -> int:
|
||||
from dbn_mqtt_tool.protocol import get_topic_for_cmd
|
||||
msg = build_request(cmd, data)
|
||||
self._mqtt.publish(get_topic_for_cmd(cmd, self._sn), msg, qos=1)
|
||||
return msg["msg_id"]
|
||||
|
||||
def _wait_resp(self, mid: int, cmd: str, timeout: float = 5.0) -> dict:
|
||||
"""等待与 msg_id 匹配的响应 (resp_queue 由 _on_message 填充)"""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline and not self._stop:
|
||||
try:
|
||||
c, payload = self._resp_queue.get(timeout=0.2)
|
||||
except queue.Empty:
|
||||
continue
|
||||
if c == cmd and payload.get("msg_id") == mid:
|
||||
return payload
|
||||
raise TimeoutError(f"{cmd} 响应超时 (msg_id={mid})")
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
size = len(self._bin)
|
||||
total_crc = ota_crc32(self._bin)
|
||||
chunks = ota_split_bin(self._bin)
|
||||
self.log.emit(f"[OTA] 镜像 {size}B CRC32=0x{total_crc:08X} → {len(chunks)} 片 @{OTA_CHUNK_SIZE}B")
|
||||
|
||||
# 1. ota_begin (断点续传定位)
|
||||
mid = self._send(CMD_OTA_BEGIN, data_ota_begin(
|
||||
size, total_crc, version=self._version, force=self._force))
|
||||
resp = self._wait_resp(mid, CMD_OTA_BEGIN)
|
||||
if resp.get("code") != 0:
|
||||
raise RuntimeError(f"ota_begin 失败: code={resp.get('code')} {resp.get('msg')} "
|
||||
f"(err_code={resp.get('data', {}).get('err_code', '-')})")
|
||||
d = resp.get("data", {})
|
||||
offset = int(d.get("offset", 0))
|
||||
self.log.emit(f"[OTA] begin OK slot={d.get('slot', '?')} state={d.get('state', '?')} "
|
||||
f"offset={offset} (断点续传点)")
|
||||
|
||||
# 2. 逐片下发 (停等: 每片等响应)
|
||||
retry = 0
|
||||
while offset < size and not self._stop:
|
||||
idx = offset // OTA_CHUNK_SIZE
|
||||
if idx >= len(chunks):
|
||||
# 设备 received 溢出, 直接截断到 size
|
||||
break
|
||||
off, crc, hexs = chunks[idx]
|
||||
mid = self._send(CMD_OTA_DATA, data_ota_data(off, crc, hexs))
|
||||
resp = self._wait_resp(mid, CMD_OTA_DATA)
|
||||
code = resp.get("code")
|
||||
if code == 0:
|
||||
offset = int(resp.get("data", {}).get("received", offset + OTA_CHUNK_SIZE))
|
||||
retry = 0
|
||||
self.progress.emit(min(offset, size), size)
|
||||
elif code == 1:
|
||||
# 缺片/乱序/CRC 失败 → 按设备指示重定位 (data.offset=received)
|
||||
new_off = int(resp.get("data", {}).get("offset", offset))
|
||||
if new_off == offset:
|
||||
retry += 1
|
||||
if retry > 3:
|
||||
raise RuntimeError("分片连续失败 (>3 次), 建议 ota_abort 后重来")
|
||||
else:
|
||||
retry = 0
|
||||
offset = new_off
|
||||
else:
|
||||
raise RuntimeError(f"ota_data@{off} 失败: code={code} {resp.get('msg')} "
|
||||
f"(err_code={resp.get('data', {}).get('err_code', '-')})")
|
||||
self.log.emit(f"[OTA] data@{off} → received={offset}")
|
||||
|
||||
if self._stop:
|
||||
self.log.emit("[OTA] 已停止")
|
||||
return
|
||||
self.progress.emit(size, size)
|
||||
|
||||
# 3. ota_end (全镜像 CRC32 复核)
|
||||
mid = self._send(CMD_OTA_END, data_ota_end(total_crc))
|
||||
resp = self._wait_resp(mid, CMD_OTA_END, timeout=10)
|
||||
if resp.get("code") != 0:
|
||||
raise RuntimeError(f"ota_end 失败: code={resp.get('code')} {resp.get('msg')} "
|
||||
f"crc_ok={resp.get('data', {}).get('crc_ok', '-')}")
|
||||
self.log.emit("[OTA] end OK crc_ok=true → state=ready, 可触发 ota_flash")
|
||||
self.finished_ok.emit("下载+校验完成, 可触发刷写 (ota_flash)")
|
||||
except Exception as e:
|
||||
self.log.emit(f"[OTA] ✗ {e}")
|
||||
self.finished_err.emit(str(e))
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
_mqtt_status = Signal(bool, str)
|
||||
_mqtt_msg = Signal(str, str, object)
|
||||
@@ -91,6 +214,7 @@ class MainWindow(QMainWindow):
|
||||
self._notebook.addTab(self._build_config_tab(), "参数配置")
|
||||
self._notebook.addTab(self._build_data_tab(), "实时数据")
|
||||
self._notebook.addTab(self._build_log_tab(), "日志")
|
||||
self._notebook.addTab(self._build_ota_tab(), "OTA")
|
||||
self._notebook.addTab(self._build_simulate_tab(), "模拟上报")
|
||||
self._notebook.addTab(self._build_proto_topic_tab(), "协议Topic")
|
||||
self._notebook.addTab(self._build_custom_topic_tab(), "自定义Topic")
|
||||
@@ -429,6 +553,192 @@ class MainWindow(QMainWindow):
|
||||
layout.addLayout(layout2)
|
||||
return w
|
||||
|
||||
def _build_ota_tab(self) -> QWidget:
|
||||
"""OTA 升级页签 (协议 V1.08: Loop 远程 OTA, 先存后刷)"""
|
||||
w = QWidget()
|
||||
layout = QVBoxLayout(w)
|
||||
|
||||
# -- 固件选择 --
|
||||
row1 = QHBoxLayout()
|
||||
row1.addWidget(QLabel("固件 bin:"))
|
||||
self._ota_file = QLineEdit()
|
||||
self._ota_file.setReadOnly(True)
|
||||
self._ota_file.setPlaceholderText("选择 Loop 固件 bin (≤96KB, 0x08003400 起 APP 映像)")
|
||||
row1.addWidget(self._ota_file, 1)
|
||||
btn_file = QPushButton("选择…")
|
||||
btn_file.clicked.connect(self._ota_choose_file)
|
||||
row1.addWidget(btn_file)
|
||||
layout.addLayout(row1)
|
||||
|
||||
# -- 参数 --
|
||||
row2 = QHBoxLayout()
|
||||
row2.addWidget(QLabel("目标版本:"))
|
||||
self._ota_version = QLineEdit("1.1.0")
|
||||
self._ota_version.setMaximumWidth(110)
|
||||
row2.addWidget(self._ota_version)
|
||||
self._ota_force = QCheckBox("force")
|
||||
self._ota_force.setToolTip("强制: 覆盖现有镜像 / 跳过安全检查 (高风险)")
|
||||
row2.addWidget(self._ota_force)
|
||||
row2.addWidget(QLabel("slot:"))
|
||||
self._ota_slot = QComboBox()
|
||||
self._ota_slot.addItems([OTA_SLOT_A, OTA_SLOT_B])
|
||||
self._ota_slot.setMaximumWidth(60)
|
||||
row2.addWidget(self._ota_slot)
|
||||
self._ota_info = QLabel("未选择固件")
|
||||
row2.addWidget(self._ota_info, 1)
|
||||
layout.addLayout(row2)
|
||||
|
||||
# -- 操作按钮 --
|
||||
row3 = QHBoxLayout()
|
||||
b_begin = QPushButton("① 下载 (begin/data/end)")
|
||||
b_begin.setToolTip("开启会话→分片下发→全镜像校验; 已下载过则断点续传")
|
||||
b_begin.clicked.connect(self._ota_start_download)
|
||||
row3.addWidget(b_begin)
|
||||
b_flash = QPushButton("② 刷写 (ota_flash)")
|
||||
b_flash.setToolTip("仅 state=ready 可触发; 安全检查 (有车拒绝) 后异步执行")
|
||||
b_flash.clicked.connect(self._ota_send_flash)
|
||||
row3.addWidget(b_flash)
|
||||
b_abort = QPushButton("中止 (ota_abort)")
|
||||
b_abort.clicked.connect(self._ota_send_abort)
|
||||
row3.addWidget(b_abort)
|
||||
b_status = QPushButton("查询状态 (ota_status)")
|
||||
b_status.clicked.connect(self._ota_send_status)
|
||||
row3.addWidget(b_status)
|
||||
b_clear = QPushButton("清空日志")
|
||||
b_clear.clicked.connect(lambda: self._ota_log_text.clear())
|
||||
row3.addWidget(b_clear)
|
||||
layout.addLayout(row3)
|
||||
|
||||
# -- 进度 --
|
||||
self._ota_progress = QProgressBar()
|
||||
self._ota_progress.setRange(0, 100)
|
||||
self._ota_progress.setValue(0)
|
||||
layout.addWidget(self._ota_progress)
|
||||
|
||||
# -- 状态日志 --
|
||||
self._ota_log_text = QPlainTextEdit()
|
||||
self._ota_log_text.setReadOnly(True)
|
||||
self._ota_log_text.setFont(QFont("Consolas", 9))
|
||||
self._ota_log_text.setMaximumBlockCount(2000)
|
||||
layout.addWidget(self._ota_log_text, 1)
|
||||
|
||||
# -- 响应队列 (下载线程 ← _on_message) --
|
||||
self._ota_resp_queue: "queue.Queue" = queue.Queue()
|
||||
self._ota_thread: Optional[OtaDownloadThread] = None
|
||||
return w
|
||||
|
||||
# ================================================================
|
||||
# OTA 升级 (V1.08)
|
||||
# ================================================================
|
||||
|
||||
def _ota_choose_file(self):
|
||||
path, _ = QFileDialog.getOpenFileName(self, "选择固件 bin", "",
|
||||
"固件文件 (*.bin);;所有文件 (*)")
|
||||
if path:
|
||||
self._ota_file.setText(path)
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
if len(data) > 96 * 1024:
|
||||
QMessageBox.warning(self, "固件过大", f"{len(data)}B 超上限 96KB (Slot 100KB-4KB)")
|
||||
crc = ota_crc32(data)
|
||||
self._ota_info.setText(f"{len(data)}B · CRC32=0x{crc:08X}")
|
||||
except OSError as e:
|
||||
QMessageBox.critical(self, "读取失败", str(e))
|
||||
|
||||
def _ota_load_bin(self) -> Optional[bytes]:
|
||||
path = self._ota_file.text()
|
||||
if not path:
|
||||
QMessageBox.warning(self, "提示", "请先选择固件 bin")
|
||||
return None
|
||||
try:
|
||||
data = open(path, "rb").read()
|
||||
except OSError as e:
|
||||
QMessageBox.critical(self, "读取失败", str(e))
|
||||
return None
|
||||
if len(data) > 96 * 1024:
|
||||
QMessageBox.warning(self, "固件过大", f"{len(data)}B 超上限 96KB")
|
||||
return None
|
||||
return data
|
||||
|
||||
def _ota_log(self, text: str):
|
||||
self._ota_log_text.appendPlainText(f"[{datetime.now().strftime('%H:%M:%S')}] {text}")
|
||||
|
||||
def _ota_start_download(self):
|
||||
sn = self._require_dev()
|
||||
if not sn:
|
||||
return
|
||||
if self._ota_thread and self._ota_thread.isRunning():
|
||||
QMessageBox.warning(self, "提示", "OTA 下载已在运行, 先中止")
|
||||
return
|
||||
data = self._ota_load_bin()
|
||||
if data is None:
|
||||
return
|
||||
self._ota_log(f"开始 OTA 下载: sn={sn} size={len(data)}B")
|
||||
self._ota_progress.setValue(0)
|
||||
self._ota_thread = OtaDownloadThread(
|
||||
self._mqtt, sn, data,
|
||||
version=self._ota_version.text().strip() or "0.0.0",
|
||||
force=self._ota_force.isChecked(),
|
||||
resp_queue=self._ota_resp_queue,
|
||||
)
|
||||
self._ota_thread.progress.connect(self._ota_on_progress)
|
||||
self._ota_thread.log.connect(self._ota_log)
|
||||
self._ota_thread.finished_ok.connect(
|
||||
lambda m: (self._ota_log(f"✓ {m}"),
|
||||
QMessageBox.information(self, "OTA", m)))
|
||||
self._ota_thread.finished_err.connect(
|
||||
lambda m: QMessageBox.critical(self, "OTA 失败", m))
|
||||
self._ota_thread.start()
|
||||
|
||||
def _ota_on_progress(self, sent: int, total: int):
|
||||
pct = int(sent * 100 / total) if total else 0
|
||||
self._ota_progress.setValue(pct)
|
||||
self._ota_progress.setFormat(f"{sent}/{total} B ({pct}%)")
|
||||
|
||||
def _ota_send_flash(self):
|
||||
sn = self._require_dev()
|
||||
if not sn:
|
||||
return
|
||||
confirm = QMessageBox.question(
|
||||
self, "确认刷写",
|
||||
"触发 ota_flash 将把暂存镜像刷入 Loop MCU (约 6~8s 窗口, 期间检测中断)。\n"
|
||||
"请确认现场无车压线圈。继续?",
|
||||
QMessageBox.Yes | QMessageBox.No)
|
||||
if confirm != QMessageBox.Yes:
|
||||
return
|
||||
self._send_cmd(CMD_OTA_FLASH, data_ota_flash(
|
||||
slot=self._ota_slot.currentText(), force=self._ota_force.isChecked()))
|
||||
|
||||
def _ota_send_abort(self):
|
||||
if self._ota_thread and self._ota_thread.isRunning():
|
||||
self._ota_thread.stop()
|
||||
self._send_cmd(CMD_OTA_ABORT, data_ota_abort())
|
||||
|
||||
def _ota_send_status(self):
|
||||
self._send_cmd(CMD_OTA_STATUS)
|
||||
|
||||
def _ota_show_status(self, data: dict):
|
||||
"""显示 ota_status / ota_report 状态"""
|
||||
line = []
|
||||
state = data.get("state", "")
|
||||
stage = data.get("stage", "")
|
||||
prog = data.get("progress", {})
|
||||
if state:
|
||||
line.append(f"state={state}({OTA_STATE_DESC.get(state, '?')})")
|
||||
if stage:
|
||||
line.append(f"stage={stage}({OTA_STAGE_DESC.get(stage, '?')})")
|
||||
if prog:
|
||||
line.append(f"progress={prog.get('sent', 0)}/{prog.get('total', 0)}B")
|
||||
if data.get("received") is not None:
|
||||
line.append(f"received={data['received']}B")
|
||||
if data.get("last_result"):
|
||||
line.append(f"last_result={data['last_result']}")
|
||||
if data.get("last_error"):
|
||||
err = OTA_ERROR_DESC.get(data["last_error"], data["last_error"])
|
||||
line.append(f"last_error={err}")
|
||||
self._ota_log("状态: " + (" | ".join(line) if line else json.dumps(data, ensure_ascii=False)))
|
||||
|
||||
# ================================================================
|
||||
# 模拟设备上报
|
||||
# ================================================================
|
||||
@@ -946,7 +1256,15 @@ class MainWindow(QMainWindow):
|
||||
self._apply_log_query_data(data)
|
||||
elif cmd == CMD_LOG_CLEAR:
|
||||
self._log(f"脱机日志已清除 (审计留痕, 需重新统计确认)")
|
||||
elif cmd in OTA_CMD_SET:
|
||||
# OTA 响应 → 喂下载线程响应队列 + 状态显示
|
||||
self._ota_resp_queue.put((cmd, payload))
|
||||
self._ota_show_status(payload.get("data", {}))
|
||||
else:
|
||||
if cmd in OTA_CMD_SET:
|
||||
self._ota_resp_queue.put((cmd, payload))
|
||||
self._ota_log(f"{cmd} 失败 code={code} {pmsg} "
|
||||
f"(err_code={payload.get('data', {}).get('err_code', '-')})")
|
||||
self._show_json({"error": f"code={code} {pmsg} ({ERROR_MSGS.get(code, '?')})"})
|
||||
|
||||
elif cmd == CMD_LOOP_DATA:
|
||||
@@ -982,6 +1300,12 @@ class MainWindow(QMainWindow):
|
||||
self._log_recv(topic, payload, f"initialize sn={dev_sn} model={model}")
|
||||
self._show_json({"online": dev_sn, "model": model, "extra": extra})
|
||||
|
||||
elif cmd == CMD_OTA_REPORT:
|
||||
# V1.08: OTA 进度/结果主动上报 (无 code 字段)
|
||||
data = payload.get("data", {})
|
||||
self._ota_show_status(data)
|
||||
self._log_recv(topic, payload, f"ota_report stage={data.get('stage', '?')}")
|
||||
|
||||
# 旧协议 / 无 cmd 字段 的消息(如 Initialize)
|
||||
elif "Method" in payload:
|
||||
self._log_recv(topic, payload, f"Method={payload.get('Method', '?')}")
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
DLD960 IoT MQTT OTA 协议单测 (V1.08)
|
||||
|
||||
验证对象: dbn_mqtt_tool/protocol.py 的 OTA 模块
|
||||
- ota_crc32: CRC-32/ISO-HDLC 标准向量
|
||||
- ota_split_bin: 分片边界 (256B 协议常量, 96KB 上限)
|
||||
- data_ota_*: 命令构建器字段
|
||||
- OtaDeviceSim: 设备侧接收状态机模拟 (协议 §4.19~4.21 规则)
|
||||
- 完整下载流: 正常/断点续传/缺片重定位/CRC 错重发/重复片幂等
|
||||
|
||||
运行: venv/bin/python -m unittest tests.test_ota_protocol -v
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dbn_mqtt_tool.protocol import (
|
||||
ota_crc32, ota_split_bin, OTA_CHUNK_SIZE, OTA_MAX_SIZE,
|
||||
data_ota_begin, data_ota_data, data_ota_end, data_ota_abort, data_ota_flash,
|
||||
OTA_TARGET_LOOP, OTA_SLOT_A,
|
||||
)
|
||||
|
||||
|
||||
class OtaDeviceSim:
|
||||
"""模拟固件 OTA 接收状态机 (协议 §4.19 ota_begin / §4.20 ota_data / §4.21 ota_end)
|
||||
|
||||
规则 (与协议文档一致):
|
||||
- 顺序片 (offset == received): 单片 CRC32 校验 → 写暂存 → received += 256
|
||||
- 重复片 (offset < received): 幂等回 code=0, 不重写
|
||||
- 缺片 (offset > received): code=1, data.offset=received (要求续传)
|
||||
- 单片 CRC 失败: code=1 (平台重发本片)
|
||||
- ota_end: received!=size → code=1; 全镜像 CRC 复核 → ready
|
||||
"""
|
||||
|
||||
def __init__(self, bin_data: bytes):
|
||||
self.bin = bin_data
|
||||
self.size = len(bin_data)
|
||||
self.state = "idle"
|
||||
self.received = 0
|
||||
self.storage = bytearray(self.size)
|
||||
|
||||
def _base(self, offset: int) -> dict:
|
||||
return {"target": OTA_TARGET_LOOP, "slot": OTA_SLOT_A,
|
||||
"offset": offset, "received": self.received,
|
||||
"size": self.size, "crc32": ota_crc32(self.bin),
|
||||
"state": self.state}
|
||||
|
||||
def ota_begin(self, req: dict) -> dict:
|
||||
# 镜像一致且已 ready → 直接返回可刷写
|
||||
if self.state == "ready" and req["size"] == self.size \
|
||||
and req["crc32"] == ota_crc32(self.bin):
|
||||
return {"code": 0, "data": self._base(self.size)}
|
||||
# downloading 中断 → 返回已接收偏移 (断点续传)
|
||||
if self.state == "downloading":
|
||||
return {"code": 0, "data": self._base(self.received)}
|
||||
self.state = "downloading"
|
||||
self.received = 0
|
||||
self.storage = bytearray(self.size)
|
||||
return {"code": 0, "data": self._base(0)}
|
||||
|
||||
def ota_data(self, req: dict) -> dict:
|
||||
offset, crc, hexs = req["offset"], req["crc32"], req["data"]
|
||||
data = bytes.fromhex(hexs)
|
||||
if offset == self.received:
|
||||
if ota_crc32(data) != crc:
|
||||
return {"code": 1, "err_code": 1, "msg": "crc mismatch",
|
||||
"data": self._base(self.received)}
|
||||
self.storage[offset:offset + len(data)] = data
|
||||
self.received = min(offset + len(data), self.size)
|
||||
return {"code": 0, "data": self._base(offset)}
|
||||
if offset < self.received:
|
||||
return {"code": 0, "data": self._base(offset)} # 幂等
|
||||
return {"code": 1, "err_code": 1, "msg": "gap/out-of-order",
|
||||
"data": self._base(self.received)}
|
||||
|
||||
def ota_end(self, req: dict) -> dict:
|
||||
if self.received != self.size:
|
||||
return {"code": 1, "data": self._base(self.received)}
|
||||
if ota_crc32(bytes(self.storage)) != req["crc32"]:
|
||||
return {"code": 5, "err_code": 5, "data": {"crc_ok": False}}
|
||||
self.state = "ready"
|
||||
return {"code": 0, "data": {"crc_ok": True}}
|
||||
|
||||
|
||||
class TestOtaCrc32(unittest.TestCase):
|
||||
def test_standard_vector(self):
|
||||
"""CRC-32/ISO-HDLC 标准校验向量: crc32('123456789') = 0xCBF43926"""
|
||||
self.assertEqual(ota_crc32(b"123456789"), 0xCBF43926)
|
||||
|
||||
def test_empty(self):
|
||||
self.assertEqual(ota_crc32(b""), 0x00000000)
|
||||
|
||||
def test_zlib_alias(self):
|
||||
import zlib
|
||||
for data in (b"", b"a", os.urandom(300)):
|
||||
self.assertEqual(ota_crc32(data), zlib.crc32(data) & 0xFFFFFFFF)
|
||||
|
||||
|
||||
class TestOtaSplitBin(unittest.TestCase):
|
||||
def test_small_single_chunk(self):
|
||||
data = b"\x00\x01\x02\x03"
|
||||
chunks = ota_split_bin(data)
|
||||
self.assertEqual(len(chunks), 1)
|
||||
off, crc, hexs = chunks[0]
|
||||
self.assertEqual(off, 0)
|
||||
self.assertEqual(hexs, data.hex())
|
||||
self.assertEqual(len(hexs), 8) # 4B → 8 hex
|
||||
self.assertEqual(crc, ota_crc32(data))
|
||||
|
||||
def test_exact_chunk(self):
|
||||
data = bytes(range(256))
|
||||
chunks = ota_split_bin(data)
|
||||
self.assertEqual(len(chunks), 1)
|
||||
self.assertEqual(len(chunks[0][2]), 512) # 256B → 512 hex
|
||||
|
||||
def test_two_chunks(self):
|
||||
data = bytes(range(256)) + b"\x00" # 257B → 2 片
|
||||
chunks = ota_split_bin(data)
|
||||
self.assertEqual(len(chunks), 2)
|
||||
self.assertEqual(chunks[0][0], 0)
|
||||
self.assertEqual(chunks[1][0], 256)
|
||||
self.assertEqual(len(chunks[1][2]), 2) # 末片 1B
|
||||
|
||||
def test_offsets_256_aligned(self):
|
||||
data = os.urandom(5000)
|
||||
for off, _, _ in ota_split_bin(data):
|
||||
self.assertEqual(off % OTA_CHUNK_SIZE, 0)
|
||||
|
||||
def test_roundtrip_reconstruct(self):
|
||||
data = os.urandom(4097)
|
||||
merged = b"".join(bytes.fromhex(hexs) for _, _, hexs in ota_split_bin(data))
|
||||
self.assertEqual(merged, data)
|
||||
|
||||
def test_over_max_size(self):
|
||||
with self.assertRaises(ValueError):
|
||||
ota_split_bin(os.urandom(OTA_MAX_SIZE + 1))
|
||||
|
||||
|
||||
class TestOtaBuilders(unittest.TestCase):
|
||||
def test_begin(self):
|
||||
d = data_ota_begin(46864, 0x12345678, version="1.1.0")
|
||||
self.assertEqual(d, {"target": "loop", "size": 46864, "crc32": 0x12345678,
|
||||
"version": "1.1.0", "force": False})
|
||||
|
||||
def test_begin_force(self):
|
||||
d = data_ota_begin(100, 1, force=True)
|
||||
self.assertTrue(d["force"])
|
||||
|
||||
def test_data(self):
|
||||
d = data_ota_data(0, 0x9E3779B9, "abcd")
|
||||
self.assertEqual(d, {"target": "loop", "offset": 0,
|
||||
"crc32": 0x9E3779B9, "data": "abcd"})
|
||||
|
||||
def test_end_abort_flash(self):
|
||||
self.assertEqual(data_ota_end(0x11223344),
|
||||
{"target": "loop", "crc32": 0x11223344})
|
||||
self.assertEqual(data_ota_abort(), {"target": "loop"})
|
||||
self.assertEqual(data_ota_flash(slot="b", force=True),
|
||||
{"target": "loop", "slot": "b", "force": True})
|
||||
|
||||
|
||||
class TestDeviceSim(unittest.TestCase):
|
||||
"""设备侧规则: 顺序/幂等/乱序/CRC/全镜像复核"""
|
||||
|
||||
def setUp(self):
|
||||
self.bin_data = bytes(range(256)) * 20 # 5120B = 20 片
|
||||
self.dev = OtaDeviceSim(self.bin_data)
|
||||
|
||||
def test_normal_flow(self):
|
||||
dev = self.dev
|
||||
chunks = ota_split_bin(self.bin_data)
|
||||
resp = dev.ota_begin(data_ota_begin(len(self.bin_data), ota_crc32(self.bin_data)))
|
||||
self.assertEqual(resp["code"], 0)
|
||||
self.assertEqual(resp["data"]["offset"], 0)
|
||||
for off, crc, hexs in chunks:
|
||||
resp = dev.ota_data(data_ota_data(off, crc, hexs))
|
||||
self.assertEqual(resp["code"], 0, f"片@{off} 应成功")
|
||||
self.assertEqual(dev.received, len(self.bin_data))
|
||||
resp = dev.ota_end(data_ota_end(ota_crc32(self.bin_data)))
|
||||
self.assertEqual(resp["code"], 0)
|
||||
self.assertTrue(resp["data"]["crc_ok"])
|
||||
self.assertEqual(dev.state, "ready")
|
||||
self.assertEqual(bytes(dev.storage), self.bin_data)
|
||||
|
||||
def test_resume_after_begin(self):
|
||||
"""断点续传: downloading 中断后 begin 返回已接收偏移"""
|
||||
dev = self.dev
|
||||
chunks = ota_split_bin(self.bin_data)
|
||||
dev.ota_begin(data_ota_begin(len(self.bin_data), ota_crc32(self.bin_data)))
|
||||
for off, crc, hexs in chunks[:7]: # 只收 7 片
|
||||
dev.ota_data(data_ota_data(off, crc, hexs))
|
||||
self.assertEqual(dev.received, 7 * OTA_CHUNK_SIZE)
|
||||
# 重新 begin → 续传点
|
||||
resp = dev.ota_begin(data_ota_begin(len(self.bin_data), ota_crc32(self.bin_data)))
|
||||
self.assertEqual(resp["data"]["offset"], 7 * OTA_CHUNK_SIZE)
|
||||
# 从续传点继续
|
||||
for off, crc, hexs in chunks[7:]:
|
||||
dev.ota_data(data_ota_data(off, crc, hexs))
|
||||
self.assertEqual(dev.received, len(self.bin_data))
|
||||
resp = dev.ota_end(data_ota_end(ota_crc32(self.bin_data)))
|
||||
self.assertTrue(resp["data"]["crc_ok"])
|
||||
|
||||
def test_dup_chunk_idempotent(self):
|
||||
"""重复片幂等: 已收片重发 → code=0, received 不变"""
|
||||
dev = self.dev
|
||||
chunks = ota_split_bin(self.bin_data)
|
||||
dev.ota_begin(data_ota_begin(len(self.bin_data), ota_crc32(self.bin_data)))
|
||||
off0, crc0, hexs0 = chunks[0]
|
||||
dev.ota_data(data_ota_data(off0, crc0, hexs0))
|
||||
r1 = dev.ota_data(data_ota_data(off0, crc0, hexs0)) # 重复
|
||||
self.assertEqual(r1["code"], 0)
|
||||
self.assertEqual(r1["data"]["received"], OTA_CHUNK_SIZE)
|
||||
|
||||
def test_gap_rejected(self):
|
||||
"""缺片: offset > received → code=1, data.offset=received"""
|
||||
dev = self.dev
|
||||
chunks = ota_split_bin(self.bin_data)
|
||||
dev.ota_begin(data_ota_begin(len(self.bin_data), ota_crc32(self.bin_data)))
|
||||
off2, crc2, hexs2 = chunks[2] # 跳过 0,1
|
||||
resp = dev.ota_data(data_ota_data(off2, crc2, hexs2))
|
||||
self.assertEqual(resp["code"], 1)
|
||||
self.assertEqual(resp["data"]["offset"], 0) # 要求从 0 续传
|
||||
|
||||
def test_chunk_crc_error(self):
|
||||
"""单片 CRC 错 → code=1, 不落盘"""
|
||||
dev = self.dev
|
||||
chunks = ota_split_bin(self.bin_data)
|
||||
dev.ota_begin(data_ota_begin(len(self.bin_data), ota_crc32(self.bin_data)))
|
||||
off0, _, hexs0 = chunks[0]
|
||||
resp = dev.ota_data(data_ota_data(off0, 0xDEADBEEF, hexs0)) # 错 crc
|
||||
self.assertEqual(resp["code"], 1)
|
||||
self.assertEqual(dev.received, 0) # 未落盘
|
||||
# 重发正确片 → 成功
|
||||
resp = dev.ota_data(data_ota_data(off0, ota_crc32(self.bin_data[:256]), hexs0))
|
||||
self.assertEqual(resp["code"], 0)
|
||||
|
||||
def test_end_incomplete(self):
|
||||
"""ota_end 时 received != size → code=1"""
|
||||
dev = self.dev
|
||||
dev.ota_begin(data_ota_begin(len(self.bin_data), ota_crc32(self.bin_data)))
|
||||
resp = dev.ota_end(data_ota_end(ota_crc32(self.bin_data)))
|
||||
self.assertEqual(resp["code"], 1)
|
||||
self.assertEqual(resp["data"]["offset"], 0)
|
||||
|
||||
def test_end_crc_fail(self):
|
||||
"""全镜像 CRC 复核失败 → code=5 crc_ok=false"""
|
||||
dev = self.dev
|
||||
chunks = ota_split_bin(self.bin_data)
|
||||
dev.ota_begin(data_ota_begin(len(self.bin_data), ota_crc32(self.bin_data)))
|
||||
for off, crc, hexs in chunks:
|
||||
dev.ota_data(data_ota_data(off, crc, hexs))
|
||||
resp = dev.ota_end(data_ota_end(0x00000000)) # 错的全镜像 crc
|
||||
self.assertEqual(resp["code"], 5)
|
||||
self.assertFalse(resp["data"]["crc_ok"])
|
||||
|
||||
|
||||
class TestDownloadFlow(unittest.TestCase):
|
||||
"""工具侧下载流程 (等价 OtaDownloadThread 核心逻辑, 无 Qt 依赖)"""
|
||||
|
||||
def _run_flow(self, bin_data: bytes, simulate_gap: bool = False):
|
||||
dev = OtaDeviceSim(bin_data)
|
||||
chunks = ota_split_bin(bin_data)
|
||||
total_crc = ota_crc32(bin_data)
|
||||
|
||||
# ota_begin
|
||||
resp = dev.ota_begin(data_ota_begin(len(bin_data), total_crc))
|
||||
assert resp["code"] == 0
|
||||
offset = resp["data"]["offset"]
|
||||
|
||||
sent_offsets = []
|
||||
gap_done = False
|
||||
while offset < len(bin_data):
|
||||
idx = offset // OTA_CHUNK_SIZE
|
||||
off, crc, hexs = chunks[idx]
|
||||
if simulate_gap and not gap_done and off == 256:
|
||||
# 模拟一次丢包: 跳过该片直接发下一片 → 设备回 gap → 重定位
|
||||
gap_done = True
|
||||
resp = dev.ota_data(data_ota_data(chunks[idx + 1][0],
|
||||
chunks[idx + 1][1],
|
||||
chunks[idx + 1][2]))
|
||||
self.assertEqual(resp["code"], 1)
|
||||
offset = resp["data"]["offset"] # 重定位
|
||||
continue
|
||||
resp = dev.ota_data(data_ota_data(off, crc, hexs))
|
||||
if resp["code"] == 0:
|
||||
offset = resp["data"]["received"]
|
||||
elif resp["code"] == 1:
|
||||
offset = resp["data"]["offset"] # 重定位
|
||||
else:
|
||||
self.fail(f"意外 code={resp['code']}")
|
||||
sent_offsets.append(off)
|
||||
|
||||
# ota_end
|
||||
resp = dev.ota_end(data_ota_end(total_crc))
|
||||
self.assertEqual(resp["code"], 0)
|
||||
self.assertTrue(resp["data"]["crc_ok"])
|
||||
self.assertEqual(dev.state, "ready")
|
||||
self.assertEqual(bytes(dev.storage), bin_data)
|
||||
return sent_offsets
|
||||
|
||||
def test_flow_normal(self):
|
||||
bin_data = os.urandom(3000)
|
||||
self._run_flow(bin_data)
|
||||
|
||||
def test_flow_with_gap_retransmit(self):
|
||||
"""缺片重传: 丢 1 片后设备要求重定位, 工具补发, 最终全量一致"""
|
||||
bin_data = os.urandom(3000)
|
||||
sent = self._run_flow(bin_data, simulate_gap=True)
|
||||
# 所有片最终都被发送过 (含补发)
|
||||
self.assertIn(256, sent)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user