Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c1f276002 | ||
|
|
2ccf0804a8 | ||
|
|
9d6f5ea412 | ||
|
|
b20fc57954 |
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,507 @@
|
|||||||
|
# DLD960 MQTT 远程 OTA 协议(Loop MCU 先存后刷)
|
||||||
|
|
||||||
|
> 文档版本 V1.01(设计稿·按修改意见修订)· 2026-08-20 · 适用范围:并入《DLD960 IoT 接口协议(MQTT + JSON)》V1.08 后生效
|
||||||
|
> 依据:`docs/ROADMAP.md` P1.4 ① —— Loop MCU (AT32F421) 远程 OTA,v1.2.x 落地
|
||||||
|
> 设计原则:**下载阶段纯 MQTT 分片 → W25Qxx 暂存;刷写阶段纯本地 ISP 透传(复用 BLE OTA 0x9F 状态机)**;传输层无关,4G 通道原样复用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1 背景与目标
|
||||||
|
|
||||||
|
## 1.1 现状基线
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 双 MCU 架构 | DBN = CH32V208(通信 MCU,跑 MQTT/BLE/TCP)+ Loop = AT32F421(地感 MCU,跑检测算法) |
|
||||||
|
| 现有 OTA 通道 | BLE → DBN 透传 → UART2 0x9F ISP → Loop bootloader(2026-08-19 修复闭环,V1.02.03) |
|
||||||
|
| 本轮目标 | MQTT → DBN 分片下载到 W25Qxx 暂存区 → 校验后**本地** ISP 刷写 Loop |
|
||||||
|
| 暂存介质 | DBN 外挂 W25Qxx(SPI1),OTA 镜像暂存区 **0x010000 起 512KB**(`SNAP_FIXED_SIZE = 参数区64KB + OTA区512KB`,已在 snapshot.c 分区模型预留) |
|
||||||
|
|
||||||
|
## 1.2 为什么"先存后刷"(相对 BLE 流式直透)
|
||||||
|
|
||||||
|
| 维度 | 流式直透(BLE 现状) | 先存后刷(本设计) |
|
||||||
|
|------|---------------------|-------------------|
|
||||||
|
| 网络抖动影响 | 任一分片丢失 → 停等卡死,全流程重来 | 下载阶段断网无所谓,**断点续传** |
|
||||||
|
| 刷写窗口 | 与传输时间重合(网络慢则窗口长) | 下载完成后刷写纯本地,窗口短且可控 |
|
||||||
|
| 校验 | 依赖传输层(BLE 无 CRC) | 单片 CRC32 + 全镜像 CRC32 双重校验 |
|
||||||
|
| 回滚 | 无 | 暂存区 Slot A/B 双槽,保留上一版重刷 |
|
||||||
|
| 安全窗口 | 无检查 | 刷写前检查无车压线圈;继电器维持 Loop 现状(与 BLE OTA 一致) |
|
||||||
|
|
||||||
|
## 1.3 范围
|
||||||
|
|
||||||
|
- 本版只做 **Loop (AT32F421) 远程 OTA**(ROADMAP P1.4 ①)
|
||||||
|
- DBN (CH32V208) 自身 OTA(ROADMAP P1.4 ②):需 bootloader 改造,**单独评审,不阻塞本设计**;协议命令预留 `target:"dbn"` 位
|
||||||
|
- 传输层:本版 MQTT;4G(UART1 AT 透传)复用同一分片下载协议(`target` 字段扩展即可)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2 总体架构与数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────┐ MQTT (dld960/{sn}/srv) ┌──────────────┐ UART2 192000 ┌─────────────┐
|
||||||
|
│ 平台 │ ── ota_begin / ota_data ─▶ │ DBN (CH32V208) │ ── 0x9F ISP ──▶ │ Loop (AT32) │
|
||||||
|
│ │ ◀─ 响应 / ota_report ───── │ │ ◀─ ACK ──────── │ bootloader │
|
||||||
|
└─────────┘ │ W25Qxx 暂存 │ └─────────────┘
|
||||||
|
│ 0x010000 512KB │
|
||||||
|
└──────────────┘
|
||||||
|
阶段1 下载:平台 MQTT 分片 → DBN 写 W25Qxx(单片 CRC32 校验)
|
||||||
|
阶段2 校验:ota_end 全镜像 CRC32 复核,state → ready
|
||||||
|
阶段3 刷写:ota_flash → DBN 从 W25Qxx 读出 → 0x9F A5/A6/A7 逐块透传 → Loop bootloader 写 flash
|
||||||
|
```
|
||||||
|
|
||||||
|
**镜像语义**:平台下发的是 **bin 原始字节**(hex 编码传输),即 AT32F421 0x08003400 起的 APP 代码映像(hex→bin 转换由平台侧完成,设备不解析 Intel HEX)。刷写时 A6 地址固定 `0x08003400`(=`APP_START_ADDR`),A7 数据按 bin 顺序发送。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3 关键参数
|
||||||
|
|
||||||
|
## 3.1 协议参数(平台 ↔ 设备契约)
|
||||||
|
|
||||||
|
| 参数 | 值 | 依据 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 下载单片原始字节 | **256B** | 2 的幂 + W25Qxx 页(256B)对齐;hex 512 字符 + JSON 外壳 ≈ 640B < `IOT_MQTT_RECV_BUF_LEN=1024` |
|
||||||
|
| 单片 CRC | **CRC32**(256B 独立计算) | 与全镜像同算法,查表实现复用 |
|
||||||
|
| 全镜像 CRC | **CRC32**(offset 0 ~ size-1 连续) | 标准 CRC-32/ISO-HDLC |
|
||||||
|
| 镜像上限 | **96KB** | Slot 数据区 100KB 预留 4KB 边界余量;Loop APP 区 0x08003400~0x08010000 = 51KB 硬上限绰绰有余,100KB 槽为 DBN (CH32V208) 自身镜像(~100KB APP 区)预留 |
|
||||||
|
| 下载片序号语义 | offset 绝对字节偏移(256 对齐) | 支持乱序/重复片幂等处理 |
|
||||||
|
| 断点续传 | ota_begin 返回已接收字节数(片对齐) | 设备持久化 received 到元数据 |
|
||||||
|
|
||||||
|
## 3.2 刷写参数(设备内部实现约束,平台无需感知)
|
||||||
|
|
||||||
|
| 参数 | 值 | 依据 |
|
||||||
|
|------|-----|------|
|
||||||
|
| A7 数据块 | **≤254B**(实现取 248B) | bootloader `LEN` 为 uint8 → DATA = LEN-1 ≤ 254(iap.c `CMD_9F_DATA_LEN`) |
|
||||||
|
| 块间超时 | 1s(可配) | 0x9F 停等协议,超时重发该块 |
|
||||||
|
| 刷写调度 | **非阻塞 tick 驱动** | 刷写状态机挂主循环轮询,每轮最多发送 1~2 块并检查 ACK,绝不阻塞主循环 → 刷写窗口内 MQTT PINGREQ/心跳/IWDG 喂狗节奏完全不受影响 |
|
||||||
|
| 刷写重试 | 每块失败重发 ×3,整体失败重试 ×3 | ROADMAP 安全底线 |
|
||||||
|
| 刷写速率 | 248B/帧 ≈ 13ms + ACK,~20ms/帧 | 64KB ≈ 265 帧 ≈ **6~8s 窗口**(Loop 典型 40~50KB 更快) |
|
||||||
|
| 升级触发 | DBN 发 `9F 01 00 01 A5 A7` → Loop APP 写 `IAP_UPGRADE_FLAG_9F=0x444C4439` → 复位 | 复用 BLE OTA 启动帧(dbn_ble_srv.c check_pkg 同款) |
|
||||||
|
|
||||||
|
## 3.3 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 字节;单片 CRC = 仅该 256B
|
||||||
|
- JSON 中 crc32 字段以**十进制无符号数**传输(与现有 uint32 字段风格一致)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4 暂存区布局(设备内部实现,命令接口不依赖)
|
||||||
|
|
||||||
|
```
|
||||||
|
0x010000 ┌─────────────────────────┐
|
||||||
|
│ OTA 元数据扇区 (4KB) │ SlotA/SlotB 头 + 会话状态 + 审计
|
||||||
|
0x011000 ├─────────────────────────┤
|
||||||
|
│ Slot A 数据区 (100KB) │ 当前新镜像(bin 原始字节)
|
||||||
|
0x02A000 ├─────────────────────────┤
|
||||||
|
│ Slot B 数据区 (100KB) │ 上一版镜像(回滚重刷)
|
||||||
|
0x043000 ├─────────────────────────┤
|
||||||
|
│ 预留 308KB │ DBN 自身镜像 / 4G 扩展
|
||||||
|
0x090000 └─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Slot A/B 各 **100KB 数据区**(`0x011000`/`0x02A000` 起,4KB 对齐),镜像上限 96KB(预留 4KB 边界余量)
|
||||||
|
- 100KB 槽位容量同时覆盖 DBN (CH32V208) 自身镜像(APP 区约 100KB),为 P1.4 ② 预留
|
||||||
|
- 4KB 边界余量:防止镜像写满后越界擦到下一槽;元数据/数据区起始均 4KB 扇区对齐
|
||||||
|
|
||||||
|
元数据结构(建议,实现可调,不进入协议字段):
|
||||||
|
|
||||||
|
```c
|
||||||
|
typedef struct { /* 64B */
|
||||||
|
uint32_t magic; /* 'DLD9' */
|
||||||
|
uint32_t state; /* idle/downloading/ready/flashing/flash_failed/aborted */
|
||||||
|
uint8_t target; /* 0=loop, 1=dbn(预留) */
|
||||||
|
uint8_t slot; /* 0=A, 1=B */
|
||||||
|
uint16_t rsv;
|
||||||
|
uint32_t size; /* 镜像字节数 */
|
||||||
|
uint32_t crc32; /* 全镜像 CRC32 */
|
||||||
|
uint32_t received; /* 已下载字节数(片对齐,断点续传依据) */
|
||||||
|
uint32_t last_result; /* 上次刷写结果码 */
|
||||||
|
uint32_t begin_ts; /* 会话开始(同步后 Unix 秒,0=未同步) */
|
||||||
|
char version[16]; /* 目标固件版本字符串(审计) */
|
||||||
|
uint32_t flash_cnt; /* 刷写尝试次数 */
|
||||||
|
uint32_t rsv2;
|
||||||
|
} OtaMeta;
|
||||||
|
```
|
||||||
|
|
||||||
|
**掉电恢复**:元数据每次 ota_begin / 每片落盘 / ota_end / 刷写结果后回写。重启后:
|
||||||
|
- state=downloading → 平台 ota_begin 时返回已接收 offset 续传
|
||||||
|
- state=ready → 平台 ota_begin 直接确认可刷写,或 ota_flash 直接触发
|
||||||
|
- state=flashing → 视为上次刷写中断(掉电/异常),可重试 ota_flash
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5 命令详表(新增,并入 MQTT 协议 V1.08)
|
||||||
|
|
||||||
|
| cmd | 方向 | 说明 |
|
||||||
|
|-----|------|------|
|
||||||
|
| `ota_begin` | srv→dev | 开启 OTA 会话 / 断点续传定位 |
|
||||||
|
| `ota_data` | srv→dev | 分片下发(256B/片,单片 CRC32) |
|
||||||
|
| `ota_end` | srv→dev | 结束下载,全镜像 CRC32 复核 |
|
||||||
|
| `ota_abort` | srv→dev | 中止会话,释放暂存 |
|
||||||
|
| `ota_flash` | srv→dev | 触发本地 ISP 刷写(仅 state=ready) |
|
||||||
|
| `ota_status` | srv→dev | 查询 OTA 状态(含进度) |
|
||||||
|
| `ota_report` | dev→srv | 刷写进度/结果主动上报(QoS 1) |
|
||||||
|
| `event_report` | dev→srv | 扩展 `type=ota_error`:重试 ×3 仍失败告警(**平台必答**,复用 §5.3 ACK 闭环) |
|
||||||
|
|
||||||
|
> 兼容:老固件(V1.07 及以下)收到 `ota_*` → 现有 default 分支回 `code=4 unsupported command`。平台下发前可用 `ota_status` 或 `dev_info_query.soft_ver` 探测能力,避免每命令必报错。
|
||||||
|
|
||||||
|
## 5.1 开启会话 `ota_begin`
|
||||||
|
|
||||||
|
> Topic: `dld960/{sn}/srv`
|
||||||
|
|
||||||
|
**请求:**
|
||||||
|
|
||||||
|
```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) |
|
||||||
|
| `crc32` | uint32 | 全镜像 CRC32(十进制) |
|
||||||
|
| `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`(防重复刷写,可 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.2 分片下发 `ota_data`
|
||||||
|
|
||||||
|
> Topic: `dld960/{sn}/srv`
|
||||||
|
|
||||||
|
**请求:**
|
||||||
|
|
||||||
|
```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` + `data.offset=received`(指示平台从该处续传;协议不要求乱序重组,降低设备复杂度)
|
||||||
|
4. 单片 CRC 失败 → 回 `code=1` + `data.err_code=1`(平台重发本片;连续失败由平台策略控制,可 abort)
|
||||||
|
5. 会话未开始 / 状态非 downloading → `code=3`(先 `ota_begin`)
|
||||||
|
6. `data` 非 512 hex 字符 / offset 非 256 对齐 / 超 size → `code=1` 参数错误
|
||||||
|
|
||||||
|
**响应:** 标准成功/失败 + data 回显 `{offset, received}`。
|
||||||
|
|
||||||
|
> 单片大小 256B 是协议常量,**不接受协商**(避免双方尺寸漂移)。若未来单片加大需协议升版。
|
||||||
|
|
||||||
|
## 5.3 结束下载 `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, data={crc_ok:false}`(msg 注明 CRC 不匹配)
|
||||||
|
|
||||||
|
## 5.4 中止会话 `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 兜底)。响应标准成功。
|
||||||
|
|
||||||
|
## 5.5 触发刷写 `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` + `data.err_code=1`(有车,拒绝;平台可提示"车辆离开后重试")
|
||||||
|
- 刷写期间会阻断检测与继电器控制 → 建议平台在低峰执行
|
||||||
|
2. 元数据非 ready → `code=3`(先 `ota_end` 完成校验)
|
||||||
|
3. 通过 → 立即回 `code=0`(异步),进入刷写:
|
||||||
|
- 写 offlog 事件日志:`固件升级开始`(target/slot/version/size)
|
||||||
|
- **暂停事件上报与脱机日志(会话期间)**:MQTT `event_report` 暂停发送(入队积压,16 深溢出丢最旧,会话结束恢复后补发);offlog/快照落盘暂停——理由:刷写期间 Loop 复位不产生检测事件,DBN 让出 SPI 总线/主循环给刷写状态机,保证保活与喂狗
|
||||||
|
- 维持 Loop 现状:复位进 bootloader 后的 GPIO/继电器状态与现网 BLE OTA 升级完全一致(该通道已验证可用),不额外干预
|
||||||
|
- 发 `9F 01 00 01 A5 A7` 启动帧 → Loop APP 写 flag 复位 → bootloader 回 pre_ok
|
||||||
|
- 发 A6 地址帧(`0x08003400` 4 字节大端)→ addr_ok
|
||||||
|
- 从 W25Qxx 读镜像,按 248B/块发 A7(停等 ACK,1s 超时重发 ×3)
|
||||||
|
- 末块(sub_amount=1)→ bootloader 写剩余 → 清 flag → 复位跑新 APP
|
||||||
|
4. 进度经 `ota_report` 上行(见 §6);失败重试 ×3 仍失败 → 元数据 `state=flash_failed` + `event_report{type:ota_error}` 告警(平台必答)
|
||||||
|
|
||||||
|
**响应:** 标准成功/失败(`code=0` 仅表示已启动,不代表刷写成功——结果以 `ota_report`/`ota_status` 为准)。
|
||||||
|
|
||||||
|
## 5.6 查询状态 `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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6 主动上报 `ota_report`
|
||||||
|
|
||||||
|
> Topic: `dld960/{sn}/dev` · QoS 1
|
||||||
|
> 用途:刷写进度与结果主动推送(进度可丢,结果可经 `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 字段 | 说明 |
|
||||||
|
|-----------|------|
|
||||||
|
| `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 同策略)。
|
||||||
|
|
||||||
|
## 6.1 失败告警(扩展 event_report)
|
||||||
|
|
||||||
|
刷写整体失败(重试 ×3 仍失败)时,设备经 `event_report` 上报(**平台必答**,复用 §5.3 ACK + 重发机制):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"msg_id": 502,
|
||||||
|
"cmd": "event_report",
|
||||||
|
"ts": 1719000008,
|
||||||
|
"data": {
|
||||||
|
"events": [
|
||||||
|
{ "type": "ota_error", "ch": 0, "value": 1003 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| type | value | 说明 |
|
||||||
|
|------|-------|------|
|
||||||
|
| `ota_error` | 0x1000 起 | `0x1001`=启动帧无响应 / `0x1002`=地址帧错误 / `0x1003`=数据块 ACK 超限 / `0x1004`=全镜像校验失败 / `0x1005`=安全窗口拒绝后强制失败 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7 安全设计(会车产品底线)
|
||||||
|
|
||||||
|
| # | 措施 | 说明 |
|
||||||
|
|---|------|------|
|
||||||
|
| 1 | **升级窗口检查** | `ota_flash` 前检查 4 通道无车;有车 → `code=3`,平台提示延迟。`force=true` 可跳过(高风险,需平台权限控制) |
|
||||||
|
| 2 | **维持 Loop 现状** | 刷写期间 Loop 行为与现网 BLE OTA 升级一致(复位进 bootloader → ISP 刷写 → 复位跑新 APP),继电器 GPIO 状态按当前硬件实测状态接受,不额外干预(BLE 通道已验证可用) |
|
||||||
|
| 3 | **双重 CRC** | 单片 CRC32(传输层抓错)+ 全镜像 CRC32(落盘完整性);刷写前读回复核 |
|
||||||
|
| 4 | **失败可重入** | 下载失败 → 断点续传;刷写失败 → 重试 ×3 → 保留镜像可重刷;AT32 ISP bootloader 兜底,不会真砖 |
|
||||||
|
| 5 | **版本回滚** | Slot A/B 双槽;平台可 `ota_begin` 指定槽刷旧版(`force=true` 覆盖) |
|
||||||
|
| 6 | **审计留痕** | 升级开始/结果写 offlog 事件日志(含目标版本、slot、结果码);平台侧 version 字段归档 |
|
||||||
|
| 7 | **能力探测** | 老固件 `ota_*` 回 `code=4`;平台按 `ota_status`/`dev_info_query.soft_ver` 判断,不盲目下发 |
|
||||||
|
| 8 | **幂等与防呆** | 重复片幂等 ACK;offset 乱序拒绝并要求续传;同版本默认拒绝重刷(force 绕过) |
|
||||||
|
| 9 | **会话期间静默** | OTA 会话期间(begin ~ 结束)暂停 MQTT `event_report` 发送(队列积压,结束后补发)与 offlog/快照落盘("升级开始"日志在暂停前写入、"升级结果"在恢复后补记);避免刷写窗口与上报/日志抢 SPI 总线与主循环 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8 错误码约定
|
||||||
|
|
||||||
|
顶层 `code` 沿用通用语义(0 成功 / 1 参数 / 2 密码 / 3 忙 / 4 不支持 / 5 内部 / 6 超长),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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9 平台侧实现要点(edc_server / DBNMQTTool)
|
||||||
|
|
||||||
|
1. **hex↔bin**:`data` hex 解码(512 hex = 256B);bin 由 Intel HEX 转换(固定 0x08003400 偏移),转换在平台完成
|
||||||
|
2. **CRC32**:`zlib.crc32(bin)` 全镜像;单片 `zlib.crc32(chunk)` 与设备逐字节一致(ISO-HDLC 即 Python 内置)
|
||||||
|
3. **分片循环**:`for offset in range(0, size, 256)` 顺序下发,收到 `code=1 + data.offset` 时从该处续传;单片失败重发 ≤3 次后 abort
|
||||||
|
4. **断点续传**:会话中断后重新 `ota_begin`,读 `data.offset` 续传
|
||||||
|
5. **结果确认**:刷写启动后轮询 `ota_status`(或订阅 `ota_report`);`stage=done` 后经 dev_info_query 核对 Loop 版本(需 Loop 侧支持版本上报,见 §10 待办)
|
||||||
|
6. **告警闭环**:收到 `event_report{type:ota_error}` 先落库后应答
|
||||||
|
7. DBNMQTTool 增加 OTA 页签(工具先行惯例:先模拟分片下发,固件后到)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 10 待板上验证项(实现前必须闭环)
|
||||||
|
|
||||||
|
| # | 项 | 影响 |
|
||||||
|
|---|-----|------|
|
||||||
|
| 1 | DBN UART2 TX 缓冲容量(能否容纳 254B A7 帧;现 BLE 透传块 ≤94B) | 刷写块大小 |
|
||||||
|
| 2 | W25Qxx 写 256B 页 + 4KB 扇区擦除在 MQTT 接收回调内的耗时(阻塞窗口 vs MQTT 保活) | 单片落盘是否需移主循环 |
|
||||||
|
| 3 | DBN RAM 预算:镜像块缓冲(248B)+ CRC 查表(1KB)是否挤占现有栈余量(历史 .bss 事故) | 内存方案 |
|
||||||
|
| 4 | Loop APP 当前固件实际 bin 大小(验证 100KB 槽余量) | 槽位容量 |
|
||||||
|
| 5 | 刷写期间 MQTT 保活验证(设计已保证非阻塞 tick 驱动,PINGREQ/心跳/IWDG 喂狗不受阻塞;板级抓包确认 6~8s 窗口内无 PINGREQ 超时断连、IWDG 不复位) | 刷写中断安全 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 11 与现有协议的关系
|
||||||
|
|
||||||
|
| 协议文档 | 变更 |
|
||||||
|
|----------|------|
|
||||||
|
| `DLD960_IoT_MQTT协议.md` | 本设计并入后升 **V1.08**:§3 命令表 + §4.19~4.24(ota_* 详情)+ §5.5(ota_report)+ §5.3 事件类型表补 `ota_error` + §8 错误码补 err_code 约定 + 修订记录 |
|
||||||
|
| `DLD960_TCP_JSON协议.md` | 本版**不扩展**(ROADMAP 先走 MQTT);后续按"复用命令 + stream/字段"模式补(与 log_* 扩展同套路) |
|
||||||
|
| `DLD960_BLE协议.md` | 不变(BLE OTA 维持流式透传现状;本地刷写状态机与 BLE 透传共用 `lup_feed_byte_ota`) |
|
||||||
|
| `README.md` / `DLD960_技术规格书.md` | 协议矩阵同步 V1.08;规格书补 OTA 分区/安全约束(并入主文档时执行) |
|
||||||
|
|
||||||
|
**固件代码落点(协议拍板后动):**
|
||||||
|
- `iot_mqtt_srv.c`:命令分发链加 6 个 ota_* 分支(现有 if-else 链)
|
||||||
|
- 新增 `ota_srv.c/h`:会话状态机 + W25Qxx 暂存读写 + CRC32 查表
|
||||||
|
- `usart_biz.c` / `loop_uart_proto.c`:本地刷写状态机(复用 `lup_feed_byte_ota` 解析 + `g_flag_counter_ota` 门控,注意退出机制——现 BLE 透传 flag 无退出点,本地刷写必须自管)
|
||||||
|
- `offlog.c`:补"固件升级开始/结果"事件类型
|
||||||
|
- 单测:`tests/test_ota_srv.c`(gcc 提取+嵌入模式,mock SPI/MQTT/Loop)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 修订记录
|
||||||
|
|
||||||
|
| 版本 | 时间 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| V1.00 | 2026-08-20 | 设计稿:Loop MCU MQTT 远程 OTA 先存后刷协议(依据 ROADMAP P1.4 ①) |
|
||||||
|
| V1.01 | 2026-08-20 | 按修改意见修订:①Slot A/B 容量 60KB→**100KB**(镜像上限 96KB,为 DBN 镜像预留)②删除"继电器 GPIO 默认态"待验证项,维持 Loop 现状(与 BLE OTA 行为一致,不额外干预)③新增"OTA 会话期间静默"约束:暂停 MQTT `event_report` 发送(队列积压结束后补发)+ offlog/快照落盘(开始/结果日志除外)④刷写调度明确**非阻塞 tick 驱动**,刷写窗口内 MQTT 保活(PINGREQ/心跳/IWDG 喂狗)不受影响 |
|
||||||
@@ -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