Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c1f276002 | ||
|
|
2ccf0804a8 |
@@ -9,6 +9,7 @@ DLD960 IoT MQTT 协议定义
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import struct
|
import struct
|
||||||
|
import zlib
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
from dataclasses import dataclass, field, asdict
|
from dataclasses import dataclass, field, asdict
|
||||||
|
|
||||||
@@ -273,6 +274,94 @@ def data_log_clear(stream: str = STREAM_EVENT) -> dict:
|
|||||||
return {}
|
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 修订)
|
## 2026-08-18 — log_query 响应改 hex 原始字节解析展示(协议 V1.07 修订)
|
||||||
|
|
||||||
### 背景
|
### 背景
|
||||||
|
|||||||
+325
-1
@@ -7,6 +7,8 @@ DBN MQTT Tool — DLD960 IoT MQTT 设备管理工具
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import queue
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -18,8 +20,9 @@ from PySide6.QtWidgets import (
|
|||||||
QTabWidget, QTextEdit, QGroupBox, QGridLayout, QCheckBox,
|
QTabWidget, QTextEdit, QGroupBox, QGridLayout, QCheckBox,
|
||||||
QSplitter, QMessageBox, QHeaderView, QFrame,
|
QSplitter, QMessageBox, QHeaderView, QFrame,
|
||||||
QComboBox, QSpinBox, QPlainTextEdit,
|
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
|
from PySide6.QtGui import QFont, QColor, QTextCursor
|
||||||
import paho.mqtt.client as mqtt
|
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,
|
data_pwd_verify, data_pwd_set, data_report_config, data_log_query, data_log_clear,
|
||||||
build_request, next_msg_id,
|
build_request, next_msg_id,
|
||||||
topic_down, topic_up,
|
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):
|
class MainWindow(QMainWindow):
|
||||||
_mqtt_status = Signal(bool, str)
|
_mqtt_status = Signal(bool, str)
|
||||||
_mqtt_msg = Signal(str, str, object)
|
_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_config_tab(), "参数配置")
|
||||||
self._notebook.addTab(self._build_data_tab(), "实时数据")
|
self._notebook.addTab(self._build_data_tab(), "实时数据")
|
||||||
self._notebook.addTab(self._build_log_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_simulate_tab(), "模拟上报")
|
||||||
self._notebook.addTab(self._build_proto_topic_tab(), "协议Topic")
|
self._notebook.addTab(self._build_proto_topic_tab(), "协议Topic")
|
||||||
self._notebook.addTab(self._build_custom_topic_tab(), "自定义Topic")
|
self._notebook.addTab(self._build_custom_topic_tab(), "自定义Topic")
|
||||||
@@ -429,6 +553,192 @@ class MainWindow(QMainWindow):
|
|||||||
layout.addLayout(layout2)
|
layout.addLayout(layout2)
|
||||||
return w
|
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)
|
self._apply_log_query_data(data)
|
||||||
elif cmd == CMD_LOG_CLEAR:
|
elif cmd == CMD_LOG_CLEAR:
|
||||||
self._log(f"脱机日志已清除 (审计留痕, 需重新统计确认)")
|
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:
|
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, '?')})"})
|
self._show_json({"error": f"code={code} {pmsg} ({ERROR_MSGS.get(code, '?')})"})
|
||||||
|
|
||||||
elif cmd == CMD_LOOP_DATA:
|
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._log_recv(topic, payload, f"initialize sn={dev_sn} model={model}")
|
||||||
self._show_json({"online": dev_sn, "model": model, "extra": extra})
|
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)
|
# 旧协议 / 无 cmd 字段 的消息(如 Initialize)
|
||||||
elif "Method" in payload:
|
elif "Method" in payload:
|
||||||
self._log_recv(topic, payload, f"Method={payload.get('Method', '?')}")
|
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)
|
||||||
@@ -59,7 +59,7 @@ DLD960 是一款基于环形线圈检测原理的四通道车辆检测器,支
|
|||||||
| [DLD960Loop_串口通信协议.md](docs/DLD960Loop_串口通信协议.md) | V1.05 | Loop MCU ↔ DBN MCU | 0x7F 帧、0xC0 传感主动上报、variation 3B 有符号 |
|
| [DLD960Loop_串口通信协议.md](docs/DLD960Loop_串口通信协议.md) | V1.05 | Loop MCU ↔ DBN MCU | 0x7F 帧、0xC0 传感主动上报、variation 3B 有符号 |
|
||||||
| [DLD960_串口通信协议.md](docs/DLD960_串口通信协议.md) | V1.01 | 整机 TTL 串口 | 设备管理、参数配置、数据上报 |
|
| [DLD960_串口通信协议.md](docs/DLD960_串口通信协议.md) | V1.01 | 整机 TTL 串口 | 设备管理、参数配置、数据上报 |
|
||||||
| [DLD960_TCP_JSON协议.md](docs/DLD960_TCP_JSON协议.md) | V1.03 | 以太网 TCP :5960 | 鉴权 + 命令 + event_report 客户端必答 + 脱机日志(事件/快照流) |
|
| [DLD960_TCP_JSON协议.md](docs/DLD960_TCP_JSON协议.md) | V1.03 | 以太网 TCP :5960 | 鉴权 + 命令 + event_report 客户端必答 + 脱机日志(事件/快照流) |
|
||||||
| [DLD960_IoT_MQTT协议.md](docs/DLD960_IoT_MQTT协议.md) | V1.07 | 云平台 MQTT | 双主题 `{sn}/srv`+`{sn}/dev`、initialize、event_report 平台必答、设备时钟同步、脱机日志(事件/快照流) |
|
| [DLD960_IoT_MQTT协议.md](docs/DLD960_IoT_MQTT协议.md) | V1.08 | 云平台 MQTT | 双主题 `{sn}/srv`+`{sn}/dev`、initialize、event_report 平台必答、设备时钟同步、脱机日志(事件/快照流)、Loop 远程 OTA(ota_* 命令,先存后刷) |
|
||||||
| [DLD960_BLE协议.md](docs/DLD960_BLE协议.md) | V1.02 | 蓝牙 BLE | 帧格式 + 分包 + 脱机日志(OFFLOG_STAT/QUERY/CLEAR)+ 传感快照(SNAP_STAT/QUERY/CLEAR,0x28/0x29/0x2A),与 MQTT/TCP 同语义 |
|
| [DLD960_BLE协议.md](docs/DLD960_BLE协议.md) | V1.02 | 蓝牙 BLE | 帧格式 + 分包 + 脱机日志(OFFLOG_STAT/QUERY/CLEAR)+ 传感快照(SNAP_STAT/QUERY/CLEAR,0x28/0x29/0x2A),与 MQTT/TCP 同语义 |
|
||||||
| [DLD960硬件资源.md](docs/DLD960硬件资源.md) | — | 硬件 | 双 MCU IO 分配、继电器、指示灯、拨码 |
|
| [DLD960硬件资源.md](docs/DLD960硬件资源.md) | — | 硬件 | 双 MCU IO 分配、继电器、指示灯、拨码 |
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,19 @@ dld960/{dev_serial}/{direction}
|
|||||||
| 5 | 内部错误 |
|
| 5 | 内部错误 |
|
||||||
| 6 | 数据超长 |
|
| 6 | 数据超长 |
|
||||||
|
|
||||||
|
### OTA 细分错误码(V1.08)
|
||||||
|
|
||||||
|
顶层 `code` 保持通用语义,`ota_*` 命令的细分错误经响应 `data.err_code` 表达:
|
||||||
|
|
||||||
|
| err_code | 场景 | 顶层 code |
|
||||||
|
|----------|------|-----------|
|
||||||
|
| 1 | 单片 CRC 失败 / 乱序缺片(`data.offset` 指示续传点) | 1 |
|
||||||
|
| 2 | 会话状态不允许(未 begin / 非 downloading 收 ota_data) | 3 |
|
||||||
|
| 3 | 安全窗口拒绝(有车压线圈) | 3 |
|
||||||
|
| 4 | 版本冲突(同版本且非 force) | 1 |
|
||||||
|
| 5 | 全镜像 CRC 不匹配(`data.crc_ok=false`) | 5 |
|
||||||
|
| 6 | 暂存区写失败(SPI 异常/满) | 5 |
|
||||||
|
|
||||||
## 2.3 设备时钟同步(`ts` 语义)
|
## 2.3 设备时钟同步(`ts` 语义)
|
||||||
|
|
||||||
设备无 RTC/SNTP 时间源,上电后本地时钟为**上电秒数**(从 0 递增)。为使上行数据带真实 Unix 时间:
|
设备无 RTC/SNTP 时间源,上电后本地时钟为**上电秒数**(从 0 递增)。为使上行数据带真实 Unix 时间:
|
||||||
@@ -140,9 +153,16 @@ dld960/{dev_serial}/{direction}
|
|||||||
| `log_stat` | 查询脱机日志统计(事件/快照流) | srv→dev | — |
|
| `log_stat` | 查询脱机日志统计(事件/快照流) | srv→dev | — |
|
||||||
| `log_query` | 分页拉取脱机日志(事件/快照流) | srv→dev | — |
|
| `log_query` | 分页拉取脱机日志(事件/快照流) | srv→dev | — |
|
||||||
| `log_clear` | 清除脱机日志(事件/快照流,审计留痕) | srv→dev | — |
|
| `log_clear` | 清除脱机日志(事件/快照流,审计留痕) | srv→dev | — |
|
||||||
|
| `ota_begin` | 开启 OTA 会话 / 断点续传定位 | srv→dev | — |
|
||||||
|
| `ota_data` | OTA 分片下发(256B/片,单片 CRC32) | srv→dev | — |
|
||||||
|
| `ota_end` | 结束 OTA 下载,全镜像 CRC32 复核 | srv→dev | — |
|
||||||
|
| `ota_abort` | 中止 OTA 会话,释放暂存 | srv→dev | — |
|
||||||
|
| `ota_flash` | 触发本地 ISP 刷写(仅 ready 态) | srv→dev | — |
|
||||||
|
| `ota_status` | 查询 OTA 状态(含进度) | srv→dev | — |
|
||||||
| `initialize` | 设备上电初始化登陆 | dev→srv | — |
|
| `initialize` | 设备上电初始化登陆 | dev→srv | — |
|
||||||
| `loop_data` | 线圈传感数据上报 | dev→srv | 0xC0 |
|
| `loop_data` | 线圈传感数据上报 | dev→srv | 0xC0 |
|
||||||
| `event_report` | 事件上报(**平台须应答**,见 §5.3) | dev→srv | — |
|
| `event_report` | 事件上报(**平台须应答**,见 §5.3) | dev→srv | — |
|
||||||
|
| `ota_report` | OTA 进度/结果主动上报 | dev→srv | — |
|
||||||
| `heartbeat` | 设备心跳 | dev→srv | — |
|
| `heartbeat` | 设备心跳 | dev→srv | — |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -798,6 +818,246 @@ dld960/{dev_serial}/{direction}
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 4.19 开启 OTA 会话 `ota_begin`
|
||||||
|
|
||||||
|
> Topic: `dld960/{sn}/srv`
|
||||||
|
> 依据《DLD960_MQTT_OTA协议.md》设计稿 V1.01(ROADMAP P1.4 ①:Loop MCU 远程 OTA,先存后刷)。
|
||||||
|
> 能力探测:老固件(V1.07 及以下)无 `ota_*` 命令,收到回 `code=4`;平台下发前用 `ota_status` 或 `dev_info_query.soft_ver` 判断。
|
||||||
|
|
||||||
|
**请求:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"msg_id": 401,
|
||||||
|
"cmd": "ota_begin",
|
||||||
|
"ts": 1719000000,
|
||||||
|
"data": {
|
||||||
|
"target": "loop",
|
||||||
|
"size": 46864,
|
||||||
|
"crc32": 305419896,
|
||||||
|
"version": "1.1.0",
|
||||||
|
"force": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| data 字段 | 类型 | 说明 |
|
||||||
|
|-----------|------|------|
|
||||||
|
| `target` | string | `loop`(当前支持);`dbn` 预留 |
|
||||||
|
| `size` | uint32 | 镜像 bin 字节数(≤ 98304 = 96KB;Slot 数据区 100KB 预留 4KB 边界余量) |
|
||||||
|
| `crc32` | uint32 | 全镜像 CRC32(十进制,算法见 §4.19.1) |
|
||||||
|
| `version` | string | 目标固件版本(写入元数据,审计用;bootloader 不校验版本) |
|
||||||
|
| `force` | bool | `true` = 覆盖现有暂存镜像 / 忽略版本冲突(默认 false) |
|
||||||
|
|
||||||
|
**设备行为:**
|
||||||
|
1. 读暂存元数据:若已有镜像且 `size+crc32` 与本次一致 →
|
||||||
|
- `state=ready` → 返回 `offset=size`(平台可直接 `ota_flash`)
|
||||||
|
- `state=downloading` → 返回 `offset=received`(断点续传)
|
||||||
|
2. 不一致 → 分配 Slot(A 当前 / B 回滚),写元数据 `state=downloading, received=0`,返回 `offset=0`
|
||||||
|
3. `force=false` 且目标版本 == 当前运行版本 → 回 `code=1, err_code=4`(防重复刷写,可 force 绕过)
|
||||||
|
|
||||||
|
**响应 data:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"msg_id": 401,
|
||||||
|
"cmd": "ota_begin",
|
||||||
|
"ts": 1719000001,
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"target": "loop",
|
||||||
|
"slot": "a",
|
||||||
|
"offset": 0,
|
||||||
|
"received": 0,
|
||||||
|
"size": 46864,
|
||||||
|
"crc32": 305419896,
|
||||||
|
"state": "downloading"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.19.1 CRC32 算法(必须双方一致)
|
||||||
|
|
||||||
|
标准 **CRC-32/ISO-HDLC**:poly `0x04C11DB7`(reflected `0xEDB88320`),init `0xFFFFFFFF`,refin/refout true,xorout `0xFFFFFFFF`。平台侧 Python `zlib.crc32()` / `binascii.crc32()` 即此算法;设备侧查表法。全镜像 CRC = offset 0 ~ size-1 连续;单片 CRC = 仅该 256B。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.20 OTA 分片下发 `ota_data`
|
||||||
|
|
||||||
|
> Topic: `dld960/{sn}/srv`
|
||||||
|
> 单片大小 **256B** 是协议常量,不接受协商;hex 512 字符 + JSON 外壳 ≈ 640B < 设备接收缓冲 1024B。
|
||||||
|
|
||||||
|
**请求:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"msg_id": 402,
|
||||||
|
"cmd": "ota_data",
|
||||||
|
"ts": 1719000002,
|
||||||
|
"data": {
|
||||||
|
"target": "loop",
|
||||||
|
"offset": 0,
|
||||||
|
"crc32": 2524764894,
|
||||||
|
"data": "6a6173646f6e...(512 hex 字符 = 256B)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| data 字段 | 类型 | 说明 |
|
||||||
|
|-----------|------|------|
|
||||||
|
| `target` | string | 同 `ota_begin` |
|
||||||
|
| `offset` | uint32 | 本片在镜像中的绝对偏移(**256 对齐**,首片 0) |
|
||||||
|
| `crc32` | uint32 | 本片 256B 的 CRC32(十进制) |
|
||||||
|
| `data` | string | 256B 原始字节小写 hex,512 字符 |
|
||||||
|
|
||||||
|
**设备行为:**
|
||||||
|
1. `offset == received`(顺序片)→ 单片 CRC32 校验 → 写 W25Qxx 暂存(256B 页对齐)→ `received += 256`(≥size 截断为 size)→ `code=0`
|
||||||
|
2. `offset < received`(重复片,平台重发)→ **幂等回 `code=0`**,不重写
|
||||||
|
3. `offset > received`(缺片/乱序)→ `code=1, err_code=1` + `data.offset=received`(指示平台从该处续传;协议不要求乱序重组)
|
||||||
|
4. 单片 CRC 失败 → `code=1, err_code=1`(平台重发本片;连续失败平台可 `ota_abort`)
|
||||||
|
5. 会话未开始 / 状态非 downloading → `code=3, err_code=2`
|
||||||
|
6. `data` 非 512 hex / offset 非 256 对齐 / 超 size → `code=1` 参数错误
|
||||||
|
|
||||||
|
**响应:** 标准成功/失败 + data 回显 `{offset, received}`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.21 结束下载 `ota_end`
|
||||||
|
|
||||||
|
> Topic: `dld960/{sn}/srv`
|
||||||
|
|
||||||
|
**请求:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"msg_id": 403,
|
||||||
|
"cmd": "ota_end",
|
||||||
|
"ts": 1719000003,
|
||||||
|
"data": {
|
||||||
|
"target": "loop",
|
||||||
|
"crc32": 305419896
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**设备行为:**
|
||||||
|
1. `received != size` → `code=1` + `data.offset=received`(不完整,续传)
|
||||||
|
2. `received == size` → 读回暂存区全镜像计算 CRC32,与 ota_begin 声明值比对
|
||||||
|
- 一致 → 元数据 `state=ready, last_result=0` → `code=0, data={crc_ok:true}`
|
||||||
|
- 不一致 → 元数据 `state=downloading`(保留已下载数据,可重发错片)→ `code=5, err_code=5, data={crc_ok:false}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.22 中止会话 `ota_abort`
|
||||||
|
|
||||||
|
> Topic: `dld960/{sn}/srv`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"msg_id": 404,
|
||||||
|
"cmd": "ota_abort",
|
||||||
|
"ts": 1719000004,
|
||||||
|
"data": { "target": "loop" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
设备行为:元数据 `state=aborted`,Slot 标记可覆盖;正在刷写时 abort → 停止发送后续 A7 块(Loop 端由 bootloader 超时复位回 APP 兜底)。响应标准成功。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.23 触发刷写 `ota_flash`
|
||||||
|
|
||||||
|
> Topic: `dld960/{sn}/srv`
|
||||||
|
> ⚠ **会车安全关键命令**:平台应确认现场允许(无车压线圈、非高峰)再下发。
|
||||||
|
|
||||||
|
**请求:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"msg_id": 405,
|
||||||
|
"cmd": "ota_flash",
|
||||||
|
"ts": 1719000005,
|
||||||
|
"data": {
|
||||||
|
"target": "loop",
|
||||||
|
"slot": "a",
|
||||||
|
"force": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| data 字段 | 类型 | 说明 |
|
||||||
|
|-----------|------|------|
|
||||||
|
| `slot` | string | `a` / `b`(缺省 = 当前 ready 的槽) |
|
||||||
|
| `force` | bool | `true` = 跳过安全窗口检查(高风险,平台授权) |
|
||||||
|
|
||||||
|
**设备行为(同步检查 → 异步刷写):**
|
||||||
|
|
||||||
|
1. **安全窗口检查**(force=false 时):4 通道 Loop 有车(VD_FLAG 任一置位)→ `code=3, err_code=3`(有车,拒绝;平台提示"车辆离开后重试")。刷写期间会阻断检测与继电器控制 → 平台建议低峰执行
|
||||||
|
2. 元数据非 ready → `code=3`(先 `ota_end` 完成校验)
|
||||||
|
3. 通过 → 立即回 `code=0`(异步),进入刷写:
|
||||||
|
- 写 offlog 事件日志:`固件升级开始`(target/slot/version/size)
|
||||||
|
- **暂停事件上报与脱机日志(会话期间)**:MQTT `event_report` 暂停发送(入队积压,16 深溢出丢最旧,会话结束恢复后补发);offlog/快照落盘暂停("升级开始"日志在暂停前写入、"升级结果"在恢复后补记)
|
||||||
|
- 维持 Loop 现状:复位进 bootloader 后的 GPIO/继电器状态与现网 BLE OTA 升级一致,不额外干预
|
||||||
|
- 发 `9F 01 00 01 A5 A7` 启动帧 → Loop APP 写 flag 复位 → bootloader 回 pre_ok
|
||||||
|
- 发 A6 地址帧(`0x08003400` 4 字节大端)→ addr_ok
|
||||||
|
- 从 W25Qxx 暂存读镜像,按 ≤254B/块发 A7(**非阻塞 tick 驱动**:每轮主循环发送 1~2 块并检查 ACK,绝不阻塞主循环,保证刷写窗口内 MQTT PINGREQ/心跳/IWDG 喂狗正常);停等 ACK,1s 超时重发 ×3
|
||||||
|
- 末块(sub_amount=1)→ bootloader 写剩余 → 清 flag → 复位跑新 APP
|
||||||
|
4. 进度经 `ota_report` 上行(§5.5);失败重试 ×3 仍失败 → 元数据 `state=flash_failed` + `event_report{type:ota_error}` 告警(平台必答)
|
||||||
|
|
||||||
|
**响应:** `code=0` 仅表示已启动,不代表刷写成功——结果以 `ota_report` / `ota_status` 为准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.24 查询状态 `ota_status`
|
||||||
|
|
||||||
|
> Topic: `dld960/{sn}/srv`
|
||||||
|
|
||||||
|
**请求:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "msg_id": 406, "cmd": "ota_status", "ts": 1719000006 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应 data:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"target": "loop",
|
||||||
|
"state": "flashing",
|
||||||
|
"slot": "a",
|
||||||
|
"size": 46864,
|
||||||
|
"received": 46864,
|
||||||
|
"crc32": 305419896,
|
||||||
|
"version": "1.1.0",
|
||||||
|
"progress": { "sent": 42112, "total": 46864 },
|
||||||
|
"last_result": 0,
|
||||||
|
"last_error": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| data 字段 | 类型 | 说明 |
|
||||||
|
|-----------|------|------|
|
||||||
|
| `state` | string | `idle` / `downloading` / `ready` / `flashing` / `flash_failed` / `aborted` |
|
||||||
|
| `progress.sent` | uint32 | 刷写阶段已送 Loop 的字节数 |
|
||||||
|
| `last_result` | uint32 | 上次刷写结果:0=无/成功,非 0=错误码 |
|
||||||
|
| `last_error` | uint32 | 上次失败细分错误码 |
|
||||||
|
|
||||||
|
**设备状态机:**
|
||||||
|
|
||||||
|
```
|
||||||
|
ota_begin(新会话) ota_data×N ota_end(CRC✓)
|
||||||
|
IDLE ─────────────────▶ DOWNLOADING ────────────▶ READY
|
||||||
|
▲ │ ▲ │
|
||||||
|
│ ota_abort │ │ ota_end(CRC✗) │ ota_flash(安全检查✓)
|
||||||
|
│ / flash_failed │ └──────────────┐ ▼
|
||||||
|
└────────────────────────┴─────────────────┴───── FLASHING ──成功──▶ (Loop 重启) ──▶ IDLE(清槽/保留)
|
||||||
|
│
|
||||||
|
└──失败×3──▶ FLASH_FAILED ──ota_abort/ota_begin──▶ IDLE
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# 5 设备主动上报
|
# 5 设备主动上报
|
||||||
|
|
||||||
## 5.1 设备上电登陆信息 `initialize`
|
## 5.1 设备上电登陆信息 `initialize`
|
||||||
@@ -967,6 +1227,7 @@ dld960/{dev_serial}/{direction}
|
|||||||
| `car_leave` | 车辆离开 | 通过时间 (×50ms) |
|
| `car_leave` | 车辆离开 | 通过时间 (×50ms) |
|
||||||
| `loop_cut` | 线圈断开 | 0 |
|
| `loop_cut` | 线圈断开 | 0 |
|
||||||
| `loop_restore` | 线圈恢复 | 断开持续时长 (×50ms) |
|
| `loop_restore` | 线圈恢复 | 断开持续时长 (×50ms) |
|
||||||
|
| `ota_error` | OTA 刷写失败告警(重试×3 仍失败,V1.08) | 0x1001=启动帧无响应 / 0x1002=地址帧错误 / 0x1003=数据块 ACK 超限 / 0x1004=全镜像校验失败 / 0x1005=安全窗口拒绝后强制失败 |
|
||||||
|
|
||||||
**平台应答(srv topic,收到后必须立即回复):**
|
**平台应答(srv topic,收到后必须立即回复):**
|
||||||
|
|
||||||
@@ -1040,6 +1301,39 @@ dld960/{dev_serial}/{direction}
|
|||||||
| `net_status` | bool | 以太网连接状态 |
|
| `net_status` | bool | 以太网连接状态 |
|
||||||
| `iot_status` | bool | MQTT 连接状态 |
|
| `iot_status` | bool | MQTT 连接状态 |
|
||||||
|
|
||||||
|
## 5.5 OTA 进度/结果上报 `ota_report`
|
||||||
|
|
||||||
|
> Topic: `dld960/{sn}/dev` · QoS 1
|
||||||
|
> 用途:OTA 会话与刷写进度/结果主动推送(V1.08)。进度可丢,结果可经 `ota_status` 兜底查询(设备侧元数据持久化 `last_result`)。
|
||||||
|
|
||||||
|
**上报:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"msg_id": 501,
|
||||||
|
"cmd": "ota_report",
|
||||||
|
"ts": 1719000007,
|
||||||
|
"data": {
|
||||||
|
"target": "loop",
|
||||||
|
"stage": "flashing",
|
||||||
|
"progress": { "sent": 42112, "total": 46864 },
|
||||||
|
"code": 0,
|
||||||
|
"msg": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| data 字段 | 说明 |
|
||||||
|
|-----------|------|
|
||||||
|
| `target` | 目标:`loop`(当前支持) |
|
||||||
|
| `stage` | `begin`(会话开启)/ `downloading`(片落盘)/ `ready`(校验通过)/ `flashing`(刷写中)/ `done`(刷写成功,Loop 已重启)/ `failed`(刷写失败) |
|
||||||
|
| `progress` | `sent`/`total` 字节(刷写阶段) |
|
||||||
|
| `code` | stage 相关结果码 |
|
||||||
|
|
||||||
|
**上报节奏**:`begin`/`ready`/`done`/`failed` 各 1 次;`flashing` 阶段按进度节流(建议每 64 块或每 8KB 一次,避免刷写期间消息风暴)。`done`/`failed` 设备侧重发 3 次(间隔 5s,同 `msg_id`/`ts`),平台去重窗口建议 10 分钟(与 `event_report` 同策略)。
|
||||||
|
|
||||||
|
**会话期间静默**(V1.08):OTA 会话期间(`ota_begin` ~ 结束)设备暂停 `event_report` 发送(入队积压,结束后补发)与 offlog/快照落盘("升级开始"日志暂停前写入、"升级结果"恢复后补记),保证刷写窗口内 MQTT 保活与 IWDG 喂狗不受影响。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 修订记录
|
# 修订记录
|
||||||
@@ -1054,4 +1348,5 @@ dld960/{dev_serial}/{direction}
|
|||||||
| V1.05 | 2026-07-15 | 增加**设备时钟同步**(§2.3,方案B):设备无 RTC,`initialize` 上线后平台经 `report_config` 命令下发 Unix `ts`,设备据此校准,之后上行 `ts` 为真实 Unix 时间;校准前为上电秒数 | wangfq |
|
| V1.05 | 2026-07-15 | 增加**设备时钟同步**(§2.3,方案B):设备无 RTC,`initialize` 上线后平台经 `report_config` 命令下发 Unix `ts`,设备据此校准,之后上行 `ts` 为真实 Unix 时间;校准前为上电秒数 | wangfq |
|
||||||
| V1.06 | 2026-08-04 | 增加**脱机事件日志**命令:`log_stat`(统计/分页定位)、`log_query`(按全局序号分页,count≤4)、`log_clear`(清除+审计留痕);§2.3 补充日志双时间戳语义(`ts_ms` 相对 + `unix_ts` 已同步,0=未同步,锚点回算规则) | wangfq |
|
| V1.06 | 2026-08-04 | 增加**脱机事件日志**命令:`log_stat`(统计/分页定位)、`log_query`(按全局序号分页,count≤4)、`log_clear`(清除+审计留痕);§2.3 补充日志双时间戳语义(`ts_ms` 相对 + `unix_ts` 已同步,0=未同步,锚点回算规则) | wangfq |
|
||||||
| V1.07 | 2026-08-18 | `log_stat` / `log_query` / `log_clear` 增加**快照流**支持(`stream=snapshot`,与 BLE 0x28/0x29/0x2A 同语义):快照统计 capacity 随芯片动态(48064~449472)、快照分页 count≤1(4 通道记录 JSON ~810B 超发送缓冲,实测修正;BLE 原始通道仍 ≤2)、快照清除审计留痕;`capacity`/`count` 类型修正为 uint32(W25Q256 事件流 130944 超 16bit) | wangfq |
|
| V1.07 | 2026-08-18 | `log_stat` / `log_query` / `log_clear` 增加**快照流**支持(`stream=snapshot`,与 BLE 0x28/0x29/0x2A 同语义):快照统计 capacity 随芯片动态(48064~449472)、快照分页 count≤1(4 通道记录 JSON ~810B 超发送缓冲,实测修正;BLE 原始通道仍 ≤2)、快照清除审计留痕;`capacity`/`count` 类型修正为 uint32(W25Q256 事件流 130944 超 16bit) | wangfq |
|
||||||
|
| V1.08 | 2026-08-20 | 增加 **Loop MCU 远程 OTA**(ROADMAP P1.4 ①,先存后刷):命令 `ota_begin` / `ota_data` / `ota_end` / `ota_abort` / `ota_flash` / `ota_status`(srv→dev)+ `ota_report`(dev→srv);单片 256B + 单片/全镜像 CRC32(ISO-HDLC,§4.19.1);断点续传(`ota_begin` 返回 offset);Slot A/B 双槽回滚(镜像 ≤96KB);`ota_flash` 安全窗口检查 + 非阻塞 tick 驱动刷写(刷写窗口内 MQTT 保活/IWDG 不受影响);会话期间暂停 `event_report` 发送与脱机日志落盘;`event_report` 扩展 `type=ota_error` 失败告警;§2.2 补 OTA 细分错误码(err_code);老固件兼容(`ota_*` 回 code=4) | wangfq |
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ DLD960 是一款基于环形线圈(LC 振荡)检测原理的四通道车辆
|
|||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| DLD960 串口通信协议 | V1.01 | TTL | 设备管理、参数配置、数据上报 |
|
| DLD960 串口通信协议 | V1.01 | TTL | 设备管理、参数配置、数据上报 |
|
||||||
| DLD960 TCP JSON 协议 | V1.03 | ETH :5960 | 密码鉴权 + 18 条命令;event_report 客户端必答(5s×3 重发);脱机日志 log_stat/log_query/log_clear(事件/快照流,stream 区分) |
|
| DLD960 TCP JSON 协议 | V1.03 | ETH :5960 | 密码鉴权 + 18 条命令;event_report 客户端必答(5s×3 重发);脱机日志 log_stat/log_query/log_clear(事件/快照流,stream 区分) |
|
||||||
| DLD960 IoT MQTT 协议 | V1.07 | ETH → Broker | 双主题 `dld960/{sn}/srv`+`/dev`;initialize 上线、loop_data 三档调度、event_report 平台必答、设备时钟同步、脱机日志 log_stat/log_query/log_clear(事件/快照流,stream 区分) |
|
| DLD960 IoT MQTT 协议 | V1.08 | ETH → Broker | 双主题 `dld960/{sn}/srv`+`/dev`;initialize 上线、loop_data 三档调度、event_report 平台必答、设备时钟同步、脱机日志 log_stat/log_query/log_clear(事件/快照流,stream 区分);Loop 远程 OTA(ota_begin/ota_data/ota_end/ota_abort/ota_flash/ota_status + ota_report,256B/片 CRC32,先存后刷断点续传,Slot A/B 双槽回滚) |
|
||||||
| DLD960Loop 串口协议 | V1.05 | MCU 间(内部) | 0x7F 帧、0xC0 传感上报(variation 3B 有符号) |
|
| DLD960Loop 串口协议 | V1.05 | MCU 间(内部) | 0x7F 帧、0xC0 传感上报(variation 3B 有符号) |
|
||||||
| DLD960 BLE 协议 | V1.02 | 蓝牙 BLE | 帧格式 + 分包;脱机日志 OFFLOG_STAT/QUERY/CLEAR + 传感快照 SNAP_STAT/QUERY/CLEAR(0x28/0x29/0x2A),与 MQTT/TCP 同语义 |
|
| DLD960 BLE 协议 | V1.02 | 蓝牙 BLE | 帧格式 + 分包;脱机日志 OFFLOG_STAT/QUERY/CLEAR + 传感快照 SNAP_STAT/QUERY/CLEAR(0x28/0x29/0x2A),与 MQTT/TCP 同语义 |
|
||||||
|
|
||||||
@@ -186,6 +186,6 @@ DLD960 是一款基于环形线圈(LC 振荡)检测原理的四通道车辆
|
|||||||
|
|
||||||
| 版本 | 修订时间 | 修订说明 | 修订人 |
|
| 版本 | 修订时间 | 修订说明 | 修订人 |
|
||||||
|------|----------|----------|--------|
|
|------|----------|----------|--------|
|
||||||
| V1.02 | 2026-08-19 | 配套整机发布 V1.02.03→V1.02.04:UART2 RX 0x9F OTA 透传机制说明(§2/§5/§7)、协议矩阵与版本配套矩阵更新、已知约束补充 OTA 无自动退出;存储适配(SPI 识别去厂商代码 + factory 写入恢复) | wangfq |
|
| V1.02 | 2026-08-19 | 配套整机发布 V1.02.03→V1.02.04:UART2 RX 0x9F OTA 透传机制说明(§2/§5/§7)、协议矩阵与版本配套矩阵更新、已知约束补充 OTA 无自动退出;存储适配(SPI 识别去厂商代码 + factory 写入恢复);2026-08-20 协议矩阵 MQTT 同步 V1.08(Loop 远程 OTA 协议设计先行,固件待实现,Slot A/B 100KB) | wangfq |
|
||||||
| V1.01 | 2026-08-18 | 配套整机发布 V1.02.01:版本矩阵更新、UART2 RX DMA 通信可靠性说明、已知约束补充 | wangfq |
|
| V1.01 | 2026-08-18 | 配套整机发布 V1.02.01:版本矩阵更新、UART2 RX DMA 通信可靠性说明、已知约束补充 | wangfq |
|
||||||
| V1.00 | 2026-07-16 | 初始版本,配套整机发布 V1.0.0 | wangfq |
|
| V1.00 | 2026-07-16 | 初始版本,配套整机发布 V1.0.0 | wangfq |
|
||||||
|
|||||||
Reference in New Issue
Block a user