8 Commits
Author SHA1 Message Date
wangfq 6c1f276002 feat(DBNMQTTool): OTA 页签 + 协议单测 (MQTT V1.08 工具先行)
- protocol.py: OTA 模块 — 命令常量/ota_crc32(ISO-HDLC)/ota_split_bin(256B×96KB)/
  data_ota_* 构建器/状态·阶段·错误码描述
- main.py: OTA 页签 — bin 选择(≤96KB+CRC32 显示)/版本·force·slot 参数/
  下载(OtaDownloadThread: begin→data×N→end, 断点续传·缺片重定位·CRC 重发)/
  刷写(二次确认)/中止/状态查询; 进度条 + ota_report/ota_status 实时显示
- tests/test_ota_protocol.py: 22 断言 — CRC32 标准向量/分片边界/构建器/
  OtaDeviceSim 设备状态机(顺序·幂等·乱序·单片CRC·全镜像复核·续传)/完整下载流
- devlog 置顶条目
2026-08-20 11:53:50 +08:00
wangfq 2ccf0804a8 docs(vd_960): MQTT 协议并入 Loop 远程 OTA → V1.08 (ROADMAP P1.4 ①)
- DLD960_IoT_MQTT协议.md V1.07→V1.08: 命令表加 ota_begin/ota_data/ota_end/
  ota_abort/ota_flash/ota_status/ota_report; §4.19~4.24 命令详情; §5.5 ota_report;
  event_report 扩展 type=ota_error; §2.2 OTA 细分错误码 (err_code); 修订记录
- DLD960_MQTT_OTA协议.md V1.01 (按修改意见): Slot A/B 100KB / 维持 Loop 现状 /
  会话期间静默 / 非阻塞 tick 驱动
- README 协议索引 + 技术规格书 §5.1 协议矩阵同步 V1.08 (CRLF 保持)
2026-08-20 11:53:36 +08:00
wangfq 9d6f5ea412 docs(vd_960): MQTT OTA 协议设计稿按修改意见修订 → V1.01
- Slot A/B 容量 60KB→100KB (镜像上限 96KB, 为 DBN CH32V208 镜像预留)
- 删除继电器 GPIO 默认态待验证项, 维持 Loop 现状 (与 BLE OTA 行为一致, 不额外干预)
- 新增会话期间静默: 暂停 MQTT event_report 发送 (队列积压结束补发) + offlog/快照落盘 (开始/结果日志除外)
- 刷写调度明确非阻塞 tick 驱动, 刷写窗口内 MQTT 保活 (PINGREQ/心跳/IWDG 喂狗) 不受影响
- 待板上验证项重新编号 (删 1 项, 保活项升级为实现约束+验证)
2026-08-20 11:37:38 +08:00
wangfq b20fc57954 docs(vd_960): MQTT 远程 OTA 协议设计稿 (Loop MCU 先存后刷, ROADMAP P1.4 ①)
- 三阶段: MQTT 分片下载→W25Qxx 暂存(0x010000 512KB)→本地 0x9F ISP 透传刷写
- 7 命令: ota_begin/ota_data/ota_end/ota_abort/ota_flash/ota_status/ota_report
  + event_report 扩展 type=ota_error 告警
- 关键参数: 单片 256B(hex, <RECV_BUF 1024), 刷写块 248B(0x9F LEN uint8 上限 254B),
  镜像上限 60KB, CRC32=ISO-HDLC(zlib.crc32 兼容), Slot A/B 双槽回滚
- 安全底线: 升级窗口检查(有车拒绝)/继电器安全侧/双重 CRC/失败重试×3/审计留痕
- 6 项待板上验证(继电器默认态/DBN TX 缓冲/SPI 写阻塞/RAM 预算/bin 大小/MQTT 保活)

协议先行, 未动固件代码; 待设计拍板后并入 MQTT 协议 V1.08
2026-08-20 11:13:43 +08:00
wangfq d4ce0ba23a chore(vd960DBN): 固件版本 1.02.03 → 1.02.04 (SPI 存储适配)
内容: SPI Flash 识别去厂商代码 + 恢复 factory 配置写入 (板级验证通过)。
README/CHANGELOG/devlog 同步; 手册/规格书配套版本更新 (文档保持 V1.02, 同日补充不升版)
2026-08-19 15:23:35 +08:00
wangfq 881a774c0c docs(vd960DBN): devlog 标注 factory 写入修复板级验证通过 (W25Q128 读写正常) 2026-08-19 15:21:40 +08:00
wangfq 8e58d5fdd1 fix(vd960DBN): 恢复 factory 配置写入 — 换新空片后配置永久丢失
8-13 止血(SPI 写触发复位)根因已 8-17 闭环(栈溢出+printf 重入, 非 SPI 问题),
offlog/snapshot 8-18 起持续 SPI 写入稳定 → 解除止血。
新空片(W25Q128)参数区无 magic + write_net_config 不写 magic → 每次上电
memory defaults, 保存配置永久丢失。恢复: mismatch 分支调 factory_dev_info()
写 magic+默认参数到 flash, 首次上电自动初始化。
2026-08-19 15:13:18 +08:00
wangfq f1d9ac4e48 fix(vd960DBN): SPI Flash 识别去厂商代码判断 — 兼容其他厂家同容量型号
- W25Qxx 宏 0XEF13~17 -> 0X13~17 (去掉厂商前缀, 只留设备 ID)
- SPI_Flash_ReadJEDEC_ID() 删除 id[0]!=0xEF||id[1]!=0x40 校验, 直接返回容量码 id[2]
  (JEDEC 容量码跨厂商标准化, offlog/snapshot 分区判断随之生效)
- storage_init() switch 改 Flash_Model & 0xFF 低字节匹配
- 换非华邦芯片 (如 0x1A 厂商) 时识别不再不一致
2026-08-19 14:45:30 +08:00
14 changed files with 1690 additions and 33 deletions
+22
View File
@@ -5,6 +5,28 @@
---
## vd960DBN V1.02.04 — 2026-08-19DBN 通信板单侧更新)
> 本次为 **vd960DBN 单侧发布**(Loop 固件未变,协议未变更,可正常配套)。
**配套版本矩阵**
| 组件 | 版本 |
|------|------|
| vd960DBN 固件 (CH32V208, DLD960GA) | **1.02.04** |
| vd960Loop 固件 (AT32F421) | 1.0(不变) |
| DLD960Loop 串口协议(0x7F / 0x9F OTA | V1.05(不变) |
| DLD960 TCP JSON 协议(:5960 | V1.03(不变) |
| DLD960 IoT MQTT 协议 | V1.07(不变) |
| DLD960 BLE 协议 | V1.02(不变) |
### 🔧 SPI Flash 存储适配修复
- **识别去厂商代码**`W25Qxx` 宏去 `0XEF` 前缀、`SPI_Flash_ReadJEDEC_ID()` 删厂商校验、`storage_init` 低字节匹配——兼容其他厂家同容量型号(如 0x1A 厂商)
- **恢复 factory 配置写入**:解除 2026-08-13 止血(根因 8-17 已闭环),换新空片(W25Q128)后不再每次上电进出厂初始化,配置读写恢复正常
- **板级验证通过**2026-08-19):W25Q128 配置读写正常
---
## vd960DBN V1.02.03 — 2026-08-19DBN 通信板单侧更新)
> 本次为 **vd960DBN 单侧发布**(Loop 固件未变,协议未变更,可正常配套)。
+89
View File
@@ -9,6 +9,7 @@ DLD960 IoT MQTT 协议定义
import json
import time
import struct
import zlib
from typing import Optional, Any
from dataclasses import dataclass, field, asdict
@@ -273,6 +274,94 @@ def data_log_clear(stream: str = STREAM_EVENT) -> dict:
return {}
# ============================================================
# OTA 远程升级 (V1.08: Loop MCU 先存后刷, ROADMAP P1.4 ①)
# 协议依据《DLD960_IoT_MQTT协议.md》§4.19~4.24 / §5.5
# ============================================================
CMD_OTA_BEGIN = "ota_begin"
CMD_OTA_DATA = "ota_data"
CMD_OTA_END = "ota_end"
CMD_OTA_ABORT = "ota_abort"
CMD_OTA_FLASH = "ota_flash"
CMD_OTA_STATUS = "ota_status"
CMD_OTA_REPORT = "ota_report" # dev→srv 主动上报
OTA_TARGET_LOOP = "loop"
OTA_CHUNK_SIZE = 256 # 单片 256B(协议常量, 不接受协商)
OTA_MAX_SIZE = 96 * 1024 # 镜像上限 96KB (Slot 100KB - 4KB 边界余量)
OTA_SLOT_A = "a"
OTA_SLOT_B = "b"
OTA_STATE_DESC = {
"idle": "空闲",
"downloading": "下载中",
"ready": "校验通过(可刷写)",
"flashing": "刷写中",
"flash_failed": "刷写失败",
"aborted": "已中止",
}
OTA_STAGE_DESC = {
"begin": "会话开启",
"downloading": "分片落盘",
"ready": "校验通过",
"flashing": "刷写中",
"done": "刷写成功(Loop 已重启)",
"failed": "刷写失败",
}
OTA_ERROR_DESC = {
0x1001: "启动帧无响应",
0x1002: "地址帧错误",
0x1003: "数据块 ACK 超限",
0x1004: "全镜像校验失败",
0x1005: "安全窗口拒绝后强制失败",
}
def ota_crc32(data: bytes) -> int:
"""CRC-32/ISO-HDLC (协议 §4.19.1; zlib.crc32 即此算法, 设备查表法一致)"""
return zlib.crc32(data) & 0xFFFFFFFF
def ota_split_bin(data: bytes, chunk_size: int = OTA_CHUNK_SIZE) -> list[tuple[int, int, str]]:
"""bin → 分片列表 [(offset, crc32, hex_str), ...] (单片 256B, hex 512 字符)"""
if len(data) > OTA_MAX_SIZE:
raise ValueError(f"镜像 {len(data)}B 超上限 {OTA_MAX_SIZE}B (96KB)")
return [(off, ota_crc32(data[off:off + chunk_size]), data[off:off + chunk_size].hex())
for off in range(0, len(data), chunk_size)]
def data_ota_begin(size: int, crc32: int, version: str = "",
target: str = OTA_TARGET_LOOP, force: bool = False) -> dict:
"""ota_begin 请求 data (开启会话 / 断点续传定位)"""
return {"target": target, "size": size, "crc32": crc32,
"version": version, "force": force}
def data_ota_data(offset: int, crc32: int, data_hex: str,
target: str = OTA_TARGET_LOOP) -> dict:
"""ota_data 请求 data (分片下发, offset 256 对齐)"""
return {"target": target, "offset": offset, "crc32": crc32, "data": data_hex}
def data_ota_end(crc32: int, target: str = OTA_TARGET_LOOP) -> dict:
"""ota_end 请求 data (结束下载, 全镜像 CRC32 复核)"""
return {"target": target, "crc32": crc32}
def data_ota_abort(target: str = OTA_TARGET_LOOP) -> dict:
"""ota_abort 请求 data (中止会话, 释放暂存)"""
return {"target": target}
def data_ota_flash(slot: str = OTA_SLOT_A, force: bool = False,
target: str = OTA_TARGET_LOOP) -> dict:
"""ota_flash 请求 data (触发本地 ISP 刷写, 仅 ready 态)"""
return {"target": target, "slot": slot, "force": force}
# ============================================================
# 解析设备上报
# ============================================================
+25
View File
@@ -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/片 hex96KB 上限)、`data_ota_begin/data/end/abort/flash` 构建器、状态/阶段/错误码中文描述
- **main.py**:新增 **OTA 页签**——固件 bin 选择(≤96KB 校验 + 全镜像 CRC32 显示)、目标版本/force/slot 参数、①下载(begin→data×N→end`OtaDownloadThread` QThread 后台 + 响应队列,断点续传/缺片重定位/CRC 错重发)②刷写(ota_flash 二次确认)③中止 ④查询状态;进度条 + 状态日志(ota_report/ota_status 实时显示)
- **tests/test_ota_protocol.py**22 断言——CRC32 标准向量、分片边界(1B/256B/257B/超限)、构建器字段、**OtaDeviceSim 设备侧状态机**(顺序/幂等/乱序拒绝/单片 CRC/全镜像复核/断点续传)+ 完整下载流(正常/缺片补发)
### 验证
- `venv/bin/python -m unittest tests.test_ota_protocol`22 全过
- offscreen MainWindow 冒烟:OTA tab 构建正常、控件齐全、512B bin → 2 片 CRC 正确
- 协议规则自洽性验证:设备模拟器按文档规则(offset==received 顺序片 / offset<received 幂等 / offset>received 拒收 code=1)与工具分片流程对跑,断点续传与缺片重定位全通
### 已知约束
- 下载线程每片停等 ACK(~512 片 × RTT);量产平台可改窗口并发,协议层无需变更
- 固件未实现 ota_* 前,工具下发会收 `code=4 unsupported`——能力探测见协议 §4.19
---
## 2026-08-18 — log_query 响应改 hex 原始字节解析展示(协议 V1.07 修订)
### 背景
+325 -1
View File
@@ -7,6 +7,8 @@ DBN MQTT Tool — DLD960 IoT MQTT 设备管理工具
import sys
import os
import json
import queue
import time
from datetime import datetime
from typing import Optional
@@ -18,8 +20,9 @@ from PySide6.QtWidgets import (
QTabWidget, QTextEdit, QGroupBox, QGridLayout, QCheckBox,
QSplitter, QMessageBox, QHeaderView, QFrame,
QComboBox, QSpinBox, QPlainTextEdit,
QFileDialog, QProgressBar,
)
from PySide6.QtCore import Qt, QTimer, Signal, Slot
from PySide6.QtCore import Qt, QTimer, Signal, Slot, QThread
from PySide6.QtGui import QFont, QColor, QTextCursor
import paho.mqtt.client as mqtt
@@ -39,8 +42,128 @@ from dbn_mqtt_tool.protocol import (
data_pwd_verify, data_pwd_set, data_report_config, data_log_query, data_log_clear,
build_request, next_msg_id,
topic_down, topic_up,
# V1.08 OTA
CMD_OTA_BEGIN, CMD_OTA_DATA, CMD_OTA_END, CMD_OTA_ABORT,
CMD_OTA_FLASH, CMD_OTA_STATUS, CMD_OTA_REPORT,
OTA_TARGET_LOOP, OTA_CHUNK_SIZE, OTA_SLOT_A, OTA_SLOT_B,
OTA_STATE_DESC, OTA_STAGE_DESC, OTA_ERROR_DESC,
data_ota_begin, data_ota_data, data_ota_end, data_ota_abort, data_ota_flash,
ota_crc32, ota_split_bin,
)
# OTA 相关命令集合 (用于响应分发)
OTA_CMD_SET = {CMD_OTA_BEGIN, CMD_OTA_DATA, CMD_OTA_END, CMD_OTA_ABORT,
CMD_OTA_FLASH, CMD_OTA_STATUS}
class OtaDownloadThread(QThread):
"""OTA 分片下载线程 (协议 V1.08): ota_begin → ota_data×N → ota_end
断点续传: ota_begin 响应 offset 为设备已接收字节数, 从此处续传
重定位: ota_data code=1 时按 data.offset 调整 (缺片/乱序/CRC)
"""
progress = Signal(int, int) # sent, total
log = Signal(str)
finished_ok = Signal(str)
finished_err = Signal(str)
def __init__(self, mqtt_client: MqttClient, dev_serial: str, bin_data: bytes,
version: str, force: bool, resp_queue: "queue.Queue"):
super().__init__()
self._mqtt = mqtt_client
self._sn = dev_serial
self._bin = bin_data
self._version = version
self._force = force
self._resp_queue = resp_queue
self._stop = False
def stop(self):
self._stop = True
def _send(self, cmd: str, data: dict) -> int:
from dbn_mqtt_tool.protocol import get_topic_for_cmd
msg = build_request(cmd, data)
self._mqtt.publish(get_topic_for_cmd(cmd, self._sn), msg, qos=1)
return msg["msg_id"]
def _wait_resp(self, mid: int, cmd: str, timeout: float = 5.0) -> dict:
"""等待与 msg_id 匹配的响应 (resp_queue 由 _on_message 填充)"""
deadline = time.time() + timeout
while time.time() < deadline and not self._stop:
try:
c, payload = self._resp_queue.get(timeout=0.2)
except queue.Empty:
continue
if c == cmd and payload.get("msg_id") == mid:
return payload
raise TimeoutError(f"{cmd} 响应超时 (msg_id={mid})")
def run(self):
try:
size = len(self._bin)
total_crc = ota_crc32(self._bin)
chunks = ota_split_bin(self._bin)
self.log.emit(f"[OTA] 镜像 {size}B CRC32=0x{total_crc:08X}{len(chunks)} 片 @{OTA_CHUNK_SIZE}B")
# 1. ota_begin (断点续传定位)
mid = self._send(CMD_OTA_BEGIN, data_ota_begin(
size, total_crc, version=self._version, force=self._force))
resp = self._wait_resp(mid, CMD_OTA_BEGIN)
if resp.get("code") != 0:
raise RuntimeError(f"ota_begin 失败: code={resp.get('code')} {resp.get('msg')} "
f"(err_code={resp.get('data', {}).get('err_code', '-')})")
d = resp.get("data", {})
offset = int(d.get("offset", 0))
self.log.emit(f"[OTA] begin OK slot={d.get('slot', '?')} state={d.get('state', '?')} "
f"offset={offset} (断点续传点)")
# 2. 逐片下发 (停等: 每片等响应)
retry = 0
while offset < size and not self._stop:
idx = offset // OTA_CHUNK_SIZE
if idx >= len(chunks):
# 设备 received 溢出, 直接截断到 size
break
off, crc, hexs = chunks[idx]
mid = self._send(CMD_OTA_DATA, data_ota_data(off, crc, hexs))
resp = self._wait_resp(mid, CMD_OTA_DATA)
code = resp.get("code")
if code == 0:
offset = int(resp.get("data", {}).get("received", offset + OTA_CHUNK_SIZE))
retry = 0
self.progress.emit(min(offset, size), size)
elif code == 1:
# 缺片/乱序/CRC 失败 → 按设备指示重定位 (data.offset=received)
new_off = int(resp.get("data", {}).get("offset", offset))
if new_off == offset:
retry += 1
if retry > 3:
raise RuntimeError("分片连续失败 (>3 次), 建议 ota_abort 后重来")
else:
retry = 0
offset = new_off
else:
raise RuntimeError(f"ota_data@{off} 失败: code={code} {resp.get('msg')} "
f"(err_code={resp.get('data', {}).get('err_code', '-')})")
self.log.emit(f"[OTA] data@{off} → received={offset}")
if self._stop:
self.log.emit("[OTA] 已停止")
return
self.progress.emit(size, size)
# 3. ota_end (全镜像 CRC32 复核)
mid = self._send(CMD_OTA_END, data_ota_end(total_crc))
resp = self._wait_resp(mid, CMD_OTA_END, timeout=10)
if resp.get("code") != 0:
raise RuntimeError(f"ota_end 失败: code={resp.get('code')} {resp.get('msg')} "
f"crc_ok={resp.get('data', {}).get('crc_ok', '-')}")
self.log.emit("[OTA] end OK crc_ok=true → state=ready, 可触发 ota_flash")
self.finished_ok.emit("下载+校验完成, 可触发刷写 (ota_flash)")
except Exception as e:
self.log.emit(f"[OTA] ✗ {e}")
self.finished_err.emit(str(e))
class MainWindow(QMainWindow):
_mqtt_status = Signal(bool, str)
_mqtt_msg = Signal(str, str, object)
@@ -91,6 +214,7 @@ class MainWindow(QMainWindow):
self._notebook.addTab(self._build_config_tab(), "参数配置")
self._notebook.addTab(self._build_data_tab(), "实时数据")
self._notebook.addTab(self._build_log_tab(), "日志")
self._notebook.addTab(self._build_ota_tab(), "OTA")
self._notebook.addTab(self._build_simulate_tab(), "模拟上报")
self._notebook.addTab(self._build_proto_topic_tab(), "协议Topic")
self._notebook.addTab(self._build_custom_topic_tab(), "自定义Topic")
@@ -429,6 +553,192 @@ class MainWindow(QMainWindow):
layout.addLayout(layout2)
return w
def _build_ota_tab(self) -> QWidget:
"""OTA 升级页签 (协议 V1.08: Loop 远程 OTA, 先存后刷)"""
w = QWidget()
layout = QVBoxLayout(w)
# -- 固件选择 --
row1 = QHBoxLayout()
row1.addWidget(QLabel("固件 bin:"))
self._ota_file = QLineEdit()
self._ota_file.setReadOnly(True)
self._ota_file.setPlaceholderText("选择 Loop 固件 bin (≤96KB, 0x08003400 起 APP 映像)")
row1.addWidget(self._ota_file, 1)
btn_file = QPushButton("选择…")
btn_file.clicked.connect(self._ota_choose_file)
row1.addWidget(btn_file)
layout.addLayout(row1)
# -- 参数 --
row2 = QHBoxLayout()
row2.addWidget(QLabel("目标版本:"))
self._ota_version = QLineEdit("1.1.0")
self._ota_version.setMaximumWidth(110)
row2.addWidget(self._ota_version)
self._ota_force = QCheckBox("force")
self._ota_force.setToolTip("强制: 覆盖现有镜像 / 跳过安全检查 (高风险)")
row2.addWidget(self._ota_force)
row2.addWidget(QLabel("slot:"))
self._ota_slot = QComboBox()
self._ota_slot.addItems([OTA_SLOT_A, OTA_SLOT_B])
self._ota_slot.setMaximumWidth(60)
row2.addWidget(self._ota_slot)
self._ota_info = QLabel("未选择固件")
row2.addWidget(self._ota_info, 1)
layout.addLayout(row2)
# -- 操作按钮 --
row3 = QHBoxLayout()
b_begin = QPushButton("① 下载 (begin/data/end)")
b_begin.setToolTip("开启会话→分片下发→全镜像校验; 已下载过则断点续传")
b_begin.clicked.connect(self._ota_start_download)
row3.addWidget(b_begin)
b_flash = QPushButton("② 刷写 (ota_flash)")
b_flash.setToolTip("仅 state=ready 可触发; 安全检查 (有车拒绝) 后异步执行")
b_flash.clicked.connect(self._ota_send_flash)
row3.addWidget(b_flash)
b_abort = QPushButton("中止 (ota_abort)")
b_abort.clicked.connect(self._ota_send_abort)
row3.addWidget(b_abort)
b_status = QPushButton("查询状态 (ota_status)")
b_status.clicked.connect(self._ota_send_status)
row3.addWidget(b_status)
b_clear = QPushButton("清空日志")
b_clear.clicked.connect(lambda: self._ota_log_text.clear())
row3.addWidget(b_clear)
layout.addLayout(row3)
# -- 进度 --
self._ota_progress = QProgressBar()
self._ota_progress.setRange(0, 100)
self._ota_progress.setValue(0)
layout.addWidget(self._ota_progress)
# -- 状态日志 --
self._ota_log_text = QPlainTextEdit()
self._ota_log_text.setReadOnly(True)
self._ota_log_text.setFont(QFont("Consolas", 9))
self._ota_log_text.setMaximumBlockCount(2000)
layout.addWidget(self._ota_log_text, 1)
# -- 响应队列 (下载线程 ← _on_message) --
self._ota_resp_queue: "queue.Queue" = queue.Queue()
self._ota_thread: Optional[OtaDownloadThread] = None
return w
# ================================================================
# OTA 升级 (V1.08)
# ================================================================
def _ota_choose_file(self):
path, _ = QFileDialog.getOpenFileName(self, "选择固件 bin", "",
"固件文件 (*.bin);;所有文件 (*)")
if path:
self._ota_file.setText(path)
try:
with open(path, "rb") as f:
data = f.read()
if len(data) > 96 * 1024:
QMessageBox.warning(self, "固件过大", f"{len(data)}B 超上限 96KB (Slot 100KB-4KB)")
crc = ota_crc32(data)
self._ota_info.setText(f"{len(data)}B · CRC32=0x{crc:08X}")
except OSError as e:
QMessageBox.critical(self, "读取失败", str(e))
def _ota_load_bin(self) -> Optional[bytes]:
path = self._ota_file.text()
if not path:
QMessageBox.warning(self, "提示", "请先选择固件 bin")
return None
try:
data = open(path, "rb").read()
except OSError as e:
QMessageBox.critical(self, "读取失败", str(e))
return None
if len(data) > 96 * 1024:
QMessageBox.warning(self, "固件过大", f"{len(data)}B 超上限 96KB")
return None
return data
def _ota_log(self, text: str):
self._ota_log_text.appendPlainText(f"[{datetime.now().strftime('%H:%M:%S')}] {text}")
def _ota_start_download(self):
sn = self._require_dev()
if not sn:
return
if self._ota_thread and self._ota_thread.isRunning():
QMessageBox.warning(self, "提示", "OTA 下载已在运行, 先中止")
return
data = self._ota_load_bin()
if data is None:
return
self._ota_log(f"开始 OTA 下载: sn={sn} size={len(data)}B")
self._ota_progress.setValue(0)
self._ota_thread = OtaDownloadThread(
self._mqtt, sn, data,
version=self._ota_version.text().strip() or "0.0.0",
force=self._ota_force.isChecked(),
resp_queue=self._ota_resp_queue,
)
self._ota_thread.progress.connect(self._ota_on_progress)
self._ota_thread.log.connect(self._ota_log)
self._ota_thread.finished_ok.connect(
lambda m: (self._ota_log(f"{m}"),
QMessageBox.information(self, "OTA", m)))
self._ota_thread.finished_err.connect(
lambda m: QMessageBox.critical(self, "OTA 失败", m))
self._ota_thread.start()
def _ota_on_progress(self, sent: int, total: int):
pct = int(sent * 100 / total) if total else 0
self._ota_progress.setValue(pct)
self._ota_progress.setFormat(f"{sent}/{total} B ({pct}%)")
def _ota_send_flash(self):
sn = self._require_dev()
if not sn:
return
confirm = QMessageBox.question(
self, "确认刷写",
"触发 ota_flash 将把暂存镜像刷入 Loop MCU (约 6~8s 窗口, 期间检测中断)。\n"
"请确认现场无车压线圈。继续?",
QMessageBox.Yes | QMessageBox.No)
if confirm != QMessageBox.Yes:
return
self._send_cmd(CMD_OTA_FLASH, data_ota_flash(
slot=self._ota_slot.currentText(), force=self._ota_force.isChecked()))
def _ota_send_abort(self):
if self._ota_thread and self._ota_thread.isRunning():
self._ota_thread.stop()
self._send_cmd(CMD_OTA_ABORT, data_ota_abort())
def _ota_send_status(self):
self._send_cmd(CMD_OTA_STATUS)
def _ota_show_status(self, data: dict):
"""显示 ota_status / ota_report 状态"""
line = []
state = data.get("state", "")
stage = data.get("stage", "")
prog = data.get("progress", {})
if state:
line.append(f"state={state}({OTA_STATE_DESC.get(state, '?')})")
if stage:
line.append(f"stage={stage}({OTA_STAGE_DESC.get(stage, '?')})")
if prog:
line.append(f"progress={prog.get('sent', 0)}/{prog.get('total', 0)}B")
if data.get("received") is not None:
line.append(f"received={data['received']}B")
if data.get("last_result"):
line.append(f"last_result={data['last_result']}")
if data.get("last_error"):
err = OTA_ERROR_DESC.get(data["last_error"], data["last_error"])
line.append(f"last_error={err}")
self._ota_log("状态: " + (" | ".join(line) if line else json.dumps(data, ensure_ascii=False)))
# ================================================================
# 模拟设备上报
# ================================================================
@@ -946,7 +1256,15 @@ class MainWindow(QMainWindow):
self._apply_log_query_data(data)
elif cmd == CMD_LOG_CLEAR:
self._log(f"脱机日志已清除 (审计留痕, 需重新统计确认)")
elif cmd in OTA_CMD_SET:
# OTA 响应 → 喂下载线程响应队列 + 状态显示
self._ota_resp_queue.put((cmd, payload))
self._ota_show_status(payload.get("data", {}))
else:
if cmd in OTA_CMD_SET:
self._ota_resp_queue.put((cmd, payload))
self._ota_log(f"{cmd} 失败 code={code} {pmsg} "
f"(err_code={payload.get('data', {}).get('err_code', '-')})")
self._show_json({"error": f"code={code} {pmsg} ({ERROR_MSGS.get(code, '?')})"})
elif cmd == CMD_LOOP_DATA:
@@ -982,6 +1300,12 @@ class MainWindow(QMainWindow):
self._log_recv(topic, payload, f"initialize sn={dev_sn} model={model}")
self._show_json({"online": dev_sn, "model": model, "extra": extra})
elif cmd == CMD_OTA_REPORT:
# V1.08: OTA 进度/结果主动上报 (无 code 字段)
data = payload.get("data", {})
self._ota_show_status(data)
self._log_recv(topic, payload, f"ota_report stage={data.get('stage', '?')}")
# 旧协议 / 无 cmd 字段 的消息(如 Initialize
elif "Method" in payload:
self._log_recv(topic, payload, f"Method={payload.get('Method', '?')}")
+316
View File
@@ -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)
+3 -3
View File
@@ -1,6 +1,6 @@
# vd_960 — DLD960 四通道车辆检测器(双 MCU 架构)
> 当前发布:**vd960DBN V1.02.03**2026-08-19DBN 单侧更新)· 完整版本见 [CHANGELOG.md](CHANGELOG.md)
> 当前发布:**vd960DBN V1.02.04**2026-08-19DBN 单侧更新)· 完整版本见 [CHANGELOG.md](CHANGELOG.md)
## 产品概述
@@ -41,7 +41,7 @@ DLD960 是一款基于环形线圈检测原理的四通道车辆检测器,支
| 子项目 | 目录 | MCU | 固件版本 | 功能 |
|--------|------|-----|---------|------|
| vd960Loop | `vd960Loop/` | AT32F421 | 1.0 | 线圈驱动、频率采样、车辆检测算法、0x7F 串口上报 |
| vd960DBN | `vd960DBN/` | CH32V208 | 1.02.03 | 蓝牙管理、TCP/IPWCHNET)、MQTT、串口桥接、4G 扩展(RFU) |
| vd960DBN | `vd960DBN/` | CH32V208 | 1.02.04 | 蓝牙管理、TCP/IPWCHNET)、MQTT、串口桥接、4G 扩展(RFU) |
> ⚠ 发布约束:自协议 V1.05 起(variation 2B无符号 → 3B有符号),**Loop 固件与 DBN 固件必须同版本配套发布**,禁止混刷。
@@ -59,7 +59,7 @@ DLD960 是一款基于环形线圈检测原理的四通道车辆检测器,支
| [DLD960Loop_串口通信协议.md](docs/DLD960Loop_串口通信协议.md) | V1.05 | Loop MCU ↔ DBN MCU | 0x7F 帧、0xC0 传感主动上报、variation 3B 有符号 |
| [DLD960_串口通信协议.md](docs/DLD960_串口通信协议.md) | V1.01 | 整机 TTL 串口 | 设备管理、参数配置、数据上报 |
| [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/CLEAR0x28/0x29/0x2A),与 MQTT/TCP 同语义 |
| [DLD960硬件资源.md](docs/DLD960硬件资源.md) | — | 硬件 | 双 MCU IO 分配、继电器、指示灯、拨码 |
+295
View File
@@ -96,6 +96,19 @@ dld960/{dev_serial}/{direction}
| 5 | 内部错误 |
| 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` 语义)
设备无 RTC/SNTP 时间源,上电后本地时钟为**上电秒数**(从 0 递增)。为使上行数据带真实 Unix 时间:
@@ -140,9 +153,16 @@ dld960/{dev_serial}/{direction}
| `log_stat` | 查询脱机日志统计(事件/快照流) | srv→dev | — |
| `log_query` | 分页拉取脱机日志(事件/快照流) | 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 | — |
| `loop_data` | 线圈传感数据上报 | dev→srv | 0xC0 |
| `event_report` | 事件上报(**平台须应答**,见 §5.3 | dev→srv | — |
| `ota_report` | OTA 进度/结果主动上报 | 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.01ROADMAP 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 = 96KBSlot 数据区 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 truexorout `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 原始字节小写 hex512 字符 |
**设备行为:**
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.1 设备上电登陆信息 `initialize`
@@ -967,6 +1227,7 @@ dld960/{dev_serial}/{direction}
| `car_leave` | 车辆离开 | 通过时间 (×50ms) |
| `loop_cut` | 线圈断开 | 0 |
| `loop_restore` | 线圈恢复 | 断开持续时长 (×50ms) |
| `ota_error` | OTA 刷写失败告警(重试×3 仍失败,V1.08) | 0x1001=启动帧无响应 / 0x1002=地址帧错误 / 0x1003=数据块 ACK 超限 / 0x1004=全镜像校验失败 / 0x1005=安全窗口拒绝后强制失败 |
**平台应答(srv topic,收到后必须立即回复):**
@@ -1040,6 +1301,39 @@ dld960/{dev_serial}/{direction}
| `net_status` | bool | 以太网连接状态 |
| `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.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≤14 通道记录 JSON ~810B 超发送缓冲,实测修正;BLE 原始通道仍 ≤2)、快照清除审计留痕;`capacity`/`count` 类型修正为 uint32W25Q256 事件流 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 + 单片/全镜像 CRC32ISO-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 |
+507
View File
@@ -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) 远程 OTAv1.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 bootloader2026-08-19 修复闭环,V1.02.03 |
| 本轮目标 | MQTT → DBN 分片下载到 W25Qxx 暂存区 → 校验后**本地** ISP 刷写 Loop |
| 暂存介质 | DBN 外挂 W25QxxSPI1),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) 自身 OTAROADMAP 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 ≤ 254iap.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 truexorout `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 原始字节小写 hex512 字符 |
**设备行为:**
1. `offset == received`(顺序片)→ 单片 CRC32 校验 → 写 W25Qxx256B 页对齐)→ `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.24ota_* 详情)+ §5.5ota_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 喂狗)不受影响 |
+4 -3
View File
@@ -1,7 +1,7 @@
# DLD960 四通道车辆检测器 产品手册
> Product Manual · 产品型号:**DLD960GA**
> 文档版本 V1.02 · 2026-08-19 · 适用固件:整机 V1.02.03Loop 1.0 + DBN 1.02.03
> 文档版本 V1.02 · 2026-08-19 · 适用固件:整机 V1.02.04Loop 1.0 + DBN 1.02.04
---
@@ -132,7 +132,7 @@ DLD960 是一款四通道环形线圈车辆检测器,一台设备即可覆盖
| 通信 MCU | 蓝牙 OTA | 小程序选择固件直接升级 |
| 检测 MCU | ISP 透传 | 小程序经蓝牙→通信 MCU 串口透传升级 |
> ⚠ **双 MCU 固件必须使用同一发布包配套升级**(V1.0.0 起为强制要求),只升级一侧会导致内部通信数据错位。当前配套:Loop 1.0 + DBN 1.02.03
> ⚠ **双 MCU 固件必须使用同一发布包配套升级**(V1.0.0 起为强制要求),只升级一侧会导致内部通信数据错位。当前配套:Loop 1.0 + DBN 1.02.04
>
> ⚠ **检测 MCU 升级完成后,若小程序无响应,请将设备断电重启**(DBN OTA 透传模式需重启恢复,V1.02.03 已知约束)。
@@ -148,6 +148,7 @@ DLD960 是一款四通道环形线圈车辆检测器,一台设备即可覆盖
| 继电器输出正常但平台无数据 | 网络配置错误;MQTT Topic/序列码不符 | 查 IP/Broker 配置;核对 dev_serial 与 Topic |
| 设备频繁自动重启 / 偶发数据丢失 | 通信 MCU 旧固件资源不足(V1.02.01 已修复) | 升级整机固件至 V1.02.01 及以上版本 |
| BLE OTA 升级失败/进度卡住 | 通信 MCU 旧固件 0x9F 透传缺陷(V1.02.03 已修复) | 升级整机固件至 V1.02.03 及以上版本 |
| 配置无法保存/每次上电恢复出厂 | 旧固件 factory 写入被禁用;换新存储芯片后参数区无出厂配置(V1.02.04 已修复) | 升级整机固件至 V1.02.04 及以上版本 |
| 事件重复上报 | 平台未按协议应答 event_report | 平台须回显 msg_id 应答(先落库后应答) |
| 线圈断开告警 | 线圈开路/引线断裂 | 万用表量线圈回路电阻(正常几欧姆以内) |
@@ -177,6 +178,6 @@ DLD960 是一款四通道环形线圈车辆检测器,一台设备即可覆盖
| 版本 | 修订时间 | 修订说明 | 修订人 |
|------|----------|----------|--------|
| V1.02 | 2026-08-19 | 配套整机发布 V1.02.03BLE OTA 升级链路加固说明(检测 MCU 0x9F 响应帧透传回小程序,停等 ACK 完整);故障排查新增 OTA 失败条目;固件升级章节补断电重启提示 | wangfq |
| V1.02 | 2026-08-19 | 配套整机发布 V1.02.03→V1.02.04:BLE OTA 升级链路加固说明(0x9F 响应帧透传,停等 ACK 完整);故障排查新增 OTA 失败 + 配置无法保存条目;固件升级补断电重启提示;存储兼容适配(去厂商代码 + 恢复 factory 写入) | wangfq |
| V1.01 | 2026-08-18 | 配套整机发布 V1.02.01:通信稳定性修复说明(UART2 DMA 防丢帧、异常重启修复);新增 BLE 脱机日志/传感快照读写说明(§5.1) | wangfq |
| V1.00 | 2026-07-16 | 初始版本,配套整机发布 V1.0.0 | wangfq |
+7 -7
View File
@@ -1,7 +1,7 @@
# DLD960 四通道车辆检测器 技术规格书
> Technical Specification · 产品型号:**DLD960GA**
> 文档版本 V1.02 · 2026-08-19 · 配套整机发布 V1.02.03
> 文档版本 V1.02 · 2026-08-19 · 配套整机发布 V1.02.04
---
@@ -100,13 +100,13 @@ DLD960 是一款基于环形线圈(LC 振荡)检测原理的四通道车辆
| 外部存储 | SPI NOR Flash | 参数存储 + 日志存储 |
| 按键 | K1 多功能按键 | 复位等 |
### 5.1 协议矩阵(V1.02.03 配套)
### 5.1 协议矩阵(V1.02.04 配套)
| 协议 | 版本 | 通道 | 要点 |
|------|------|------|------|
| DLD960 串口通信协议 | V1.01 | TTL | 设备管理、参数配置、数据上报 |
| 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 远程 OTAota_begin/ota_data/ota_end/ota_abort/ota_flash/ota_status + ota_report256B/片 CRC32,先存后刷断点续传,Slot A/B 双槽回滚) |
| DLD960Loop 串口协议 | V1.05 | MCU 间(内部) | 0x7F 帧、0xC0 传感上报(variation 3B 有符号) |
| DLD960 BLE 协议 | V1.02 | 蓝牙 BLE | 帧格式 + 分包;脱机日志 OFFLOG_STAT/QUERY/CLEAR + 传感快照 SNAP_STAT/QUERY/CLEAR0x28/0x29/0x2A),与 MQTT/TCP 同语义 |
@@ -166,14 +166,14 @@ DLD960 是一款基于环形线圈(LC 振荡)检测原理的四通道车辆
> ⚠ **自内部协议 V1.05 起,vd960Loop 与 vd960DBN 固件必须同版本配套刷写**variation 字段 2B→3B 帧格式变更,混刷导致数据静默错位)。
| 组件 | V1.02.03 配套版本 |
| 组件 | V1.02.04 配套版本 |
|------|-------------------|
| vd960Loop 固件 | 1.0(不变) |
| vd960DBN 固件 | 1.02.03 |
| vd960DBN 固件 | 1.02.04 |
### 10.1 已知约束(V1.02.01
- 固件版本三段式:`FIRMWARE_VER="1.02.03"`MAIN=1 / SUB=2 / SUBSUB=3);BLE 上报仅 MAIN/SUB 两字节(1.02),SUBSUB 只体现在字符串上报(MQTT/TCP JSON)。
- 固件版本三段式:`FIRMWARE_VER="1.02.04"`MAIN=1 / SUB=2 / SUBSUB=4);BLE 上报仅 MAIN/SUB 两字节(1.02),SUBSUB 只体现在字符串上报(MQTT/TCP JSON)。
- 设备无 RTC,时钟同步依赖平台下发(校准前 ts 为上电秒数)。
- CH32V208 RAM 预算:BLE 协议栈固定占用低 16KB(不可裁剪),用户可用 48KB;固件经 RAM 瘦身优化,栈余量约 6KB,现场升级务必使用 V1.02.01 及以上版本。
- 传感快照区延后 3s 初始化(开机 3s 内传感数据不落盘,设计接受)。
@@ -186,6 +186,6 @@ DLD960 是一款基于环形线圈(LC 振荡)检测原理的四通道车辆
| 版本 | 修订时间 | 修订说明 | 修订人 |
|------|----------|----------|--------|
| V1.02 | 2026-08-19 | 配套整机发布 V1.02.03UART2 RX 0x9F OTA 透传机制说明(§2/§5/§7)、协议矩阵与版本配套矩阵更新、已知约束补充 OTA 无自动退出 | wangfq |
| V1.02 | 2026-08-19 | 配套整机发布 V1.02.03→V1.02.04UART2 RX 0x9F OTA 透传机制说明(§2/§5/§7)、协议矩阵与版本配套矩阵更新、已知约束补充 OTA 无自动退出;存储适配(SPI 识别去厂商代码 + factory 写入恢复);2026-08-20 协议矩阵 MQTT 同步 V1.08Loop 远程 OTA 协议设计先行,固件待实现,Slot A/B 100KB | wangfq |
| V1.01 | 2026-08-18 | 配套整机发布 V1.02.01:版本矩阵更新、UART2 RX DMA 通信可靠性说明、已知约束补充 | wangfq |
| V1.00 | 2026-07-16 | 初始版本,配套整机发布 V1.0.0 | wangfq |
@@ -241,12 +241,15 @@ void load_cfg_from_flash(void)
if(strncmp(mBuff, DEV_USER_FLASH_MAGIC, sizeof(DEV_USER_FLASH_MAGIC)) != 0)
{
free(mBuff);
/* ===== 止血 (2026-08-13): factory SPI 写触发硬件复位死循环 =====
: SPI_Flash_Write( 0x00, 512B) RST=0x00000000
magic SPI ()
: Flash, () */
/* 2026-08-17 栈溢出修复后恢复 (栈~4.75KB) */
PRINT("CFG: magic mismatch - MEMORY DEFAULTS (factory SPI write disabled)\n");
/* ===== 恢复 factory 写入 (2026-08-19) =====
8-13 : factory SPI 8-17
(.bss RAM + printf , SPI );
offlog/snapshot 8-18 SPI
(W25Q128) magic memory defaults,
write_net_config magic
: mismatch (magic+) flash, */
factory_dev_info();
PRINT("CFG: magic mismatch - factory defaults written\n");
memset(g_dev_number, 0, 6);
memcpy(g_dev_number, gMacAddr, 6);
sprintf(g_dev_number_str, "%02X%02X%02X%02X%02X%02X",
@@ -290,8 +293,7 @@ void load_cfg_from_flash(void)
g_sub_code_enable.dgdus_enable = 0;
g_sub_code_enable.wbdus_enable = 0;
g_sub_code_enable.radar_enable = 0;
/* 2026-08-17 栈溢出修复后恢复 */
PRINT("CFG: memory defaults OK (no factory write)\n");
PRINT("CFG: memory defaults OK (factory written)\n");
return;
}
@@ -11,11 +11,11 @@
#include <stdint.h>
#define PRODUCT_MODEL "DLD960GA"
#define FIRMWARE_VER "1.02.03"
#define FIRMWARE_VER "1.02.04"
#define HARDWARE_VER "1.0"
#define FIRMWARE_VER_MAIN 1
#define FIRMWARE_VER_SUB 2
#define FIRMWARE_VER_SUBSUB 3
#define FIRMWARE_VER_SUBSUB 4
#define HARDWARE_VER_MAIN 1
#define HARDWARE_VER_SUB 1
@@ -19,11 +19,11 @@
/* Winbond SPIFalsh ID */
#define W25Q80 0XEF13
#define W25Q16 0XEF14
#define W25Q32 0XEF15
#define W25Q64 0XEF16 //64Mbit, 8MByte
#define W25Q128 0XEF17
#define W25Q80 0X13 // device ID only, no vendor code
#define W25Q16 0X14
#define W25Q32 0X15
#define W25Q64 0X16 //64Mbit, 8MByte
#define W25Q128 0X17
/* Winbond SPIFalsh Instruction List */
#define W25X_WriteEnable 0x06
@@ -228,8 +228,10 @@ u16 SPI_Flash_ReadID(void)
/*********************************************************************
* @fn SPI_Flash_ReadJEDEC_ID
*
* @brief Read JEDEC ID (0x9F). Return 3rd byte (device id).
* W25Q32=0x16 Q64=0x17 Q128=0x18 Q256=0x19; 0 if not Winbond.
* @brief Read JEDEC ID (0x9F). Return 3rd byte (capacity code).
* W25Q32=0x16 Q64=0x17 Q128=0x18 Q256=0x19.
* 2026-08-19: no vendor code check (id[0]/id[1]) - compatible
* with other vendors same-capacity parts.
*
* @return device id byte
*/
@@ -244,7 +246,8 @@ uint8_t SPI_Flash_ReadJEDEC_ID(void)
id[2] = SPI1_ReadWriteByte(0xFF);
GPIO_WriteBit(GPIOA, GPIO_Pin_4, 1);
if (id[0] != 0xEF || id[1] != 0x40) return 0;
/* 2026-08-19: no vendor code check - JEDEC capacity code (id[2])
is standardized across vendors (e.g. 0x16=32Mbit for ISSI/GD too) */
return id[2];
}
@@ -505,7 +508,7 @@ void storage_init(void)
SPI_Flash_Init();
uint16_t Flash_Model = SPI_Flash_ReadID();
switch(Flash_Model)
switch(Flash_Model & 0xFF) /* device ID low byte only, no vendor check */
{
case W25Q80: PRINT("W25Q80 OK!\r\n"); break;
case W25Q16: PRINT("W25Q16 OK!\r\n"); break;
+73
View File
@@ -4,6 +4,76 @@
>
> 项目定位: DLD960 通信板 — BLE 配网、TCP JSON 协议服务、Loop MCU 串口桥接
## 2026-08-19 — 固件版本 V1.02.04(SPI 存储适配两项修复)
### 内容
| 项 | 说明 |
|------|------|
| SPI Flash 识别去厂商代码 | `W25Qxx` 宏去 `0XEF` 前缀、`SPI_Flash_ReadJEDEC_ID()` 删厂商校验、`storage_init` 低字节匹配(f1d9ac4 |
| 恢复 factory 配置写入 | 解除 8-13 止血,空片首启写 magic+默认参数,配置读写恢复正常(8e58d5f + 881a774 |
| 版本号 | `cmcng.h` 三段式一致:FIRMWARE_VER="1.02.04" + MAIN=1/SUB=2/SUBSUB=4 |
**板级验证**:W25Q128 配置读写正常(用户 2026-08-19 确认)。
文档同步:README 当前发布/版本表、CHANGELOG V1.02.04 条目、产品手册/技术规格书配套版本(文档保持 V1.02,同日内补充不升版)。
---
## 2026-08-19 — 恢复 factory 配置写入(换新空片后"老是进入出厂初始化配置")
### 背景
换 W25Q128 新片后每次上电日志 `CFG: magic mismatch - MEMORY DEFAULTS (factory SPI write disabled)`,用户保存的配置不生效。
### 根因
1. **8-13 止血禁用了 factory 写入**(当时 SPI 写触发硬件复位死循环,临时方案:不写 Flash 用内存默认)——但该根因已在 8-17 闭环(.bss 挤占 RAM → 栈溢出 + printf 重入,**非 SPI 硬件问题**),offlog/snapshot 自 8-18 起持续 SPI 写入稳定,止血早该解除
2. **新空片(W25Q128)参数区全 0xFF**magicPRODUCT_MODEL 字符串)不匹配 → 每次上电走内存默认
3. **`write_net_config`/`set_ble_safe_pass` 保存配置只改字段不写 magic** → 即使保存过,下次上电 magic 仍 0xFF → 配置"永久丢失"
### 变更(cfig_flash.cUTF-8+CRLF
`load_cfg_from_flash()` mismatch 分支:删除"不写 Flash"止血,恢复调用 `factory_dev_info()`(写 magic + 默认参数 + 网络配置到参数区 0x00),打印改 `factory defaults written`。首次上电自动初始化参数区,后续上电正常加载 + 保存配置生效。
### 验证
- `check_c_balance.py` OK`gcc -fsyntax-only` 无新错误(仅原有 unused warning
- SPI 读写实现对 W25Q128 兼容确认:4KB 扇区擦除 + 256B 页编程分页(storage.c 标准实现),指令集与 W25Q32 相同
### 板级验证(用户 2026-08-19 确认)
- ✅ **读写正常**:新片(W25Q128)配置读写恢复正常,不再每次上电进出厂初始化
- 新芯片规格书无需再核对(华邦 W25Q128,与 W25Q32 指令/结构兼容)
---
## 2026-08-19 — SPI Flash 识别去掉厂商代码判断(兼容多厂家同容量型号)
### 背景
`SPI_Flash_ReadID()`0x90 指令)返回 `0xEF15`(华邦:厂商 0xEF + 设备 ID 0x15),`SPI_Flash_ReadJEDEC_ID()`(0x9F 指令)返回 3 字节(0xEF 0x40 0x16)。原代码在 JEDEC 读取处硬编码校验 `id[0]==0xEF && id[1]==0x40`,且 `W25Qxx` 宏含 `0XEF` 厂商前缀——换其他厂家同容量型号(如 0x1A 开头)时识别不一致。
### 变更(storage.cGBK+CRLF 保持原编码)
| 位置 | 改动 |
|------|------|
| `W25Q80~W25Q128` 宏 | `0XEF13~0XEF17``0X13~0X17`(去掉厂商前缀,只留设备 ID |
| `SPI_Flash_ReadJEDEC_ID()` | 删除 `if (id[0] != 0xEF \|\| id[1] != 0x40) return 0;`,直接 `return id[2];`(JEDEC 容量码跨厂商标准化:0x16=32Mbit 等) |
| `storage_init()` | `switch(Flash_Model)``switch(Flash_Model & 0xFF)`(只匹配设备 ID 低字节) |
### 验证
- `check_c_balance.py` OK`gcc -fsyntax-only` 无新错误(仅原有 unused warning
- 逻辑说明:真正决定分区容量的是 offlog/snapshot 用 `SPI_Flash_ReadJEDEC_ID()` 容量码(0x16/0x17/0x18/0x19)匹配 `OFFLOG_CHIP_*`——这部分本来就不含厂商判断,随本次一并生效
### 遗留
- `/* Winbond SPIFalsh ID */` 历史注释未改(宏已不限于 Winbond,后续可顺手更新)
- 待板级:换非华邦同容量芯片实测识别
---
## 2026-08-19 — UART2 RX DMA 下 BLE→Loop OTA 失效修复:0x9F 帧透传回 BLE
### 背景
@@ -1416,6 +1486,9 @@ TCP 超时要等 ~2 分钟才触发 `SINT_STAT_TIM_OUT`,
| 版本 | 时间 | 说明 |
|------|------|------|
| V4.7 | 2026-08-19 | 固件版本 1.02.03→1.02.04 (SPI 存储适配: 去厂商识别 + 恢复 factory 写入, 板级验证通过) |
| V4.6 | 2026-08-19 | 恢复 factory 配置写入: 解除 8-13 止血, 空片首启写 magic+默认参数 (换 W25Q128 后配置永久丢失修复) |
| V4.5 | 2026-08-19 | SPI Flash 识别去厂商代码: W25Qxx 宏去 0XEF 前缀, ReadJEDEC_ID 删厂商校验, storage_init 低字节匹配 (兼容多厂家) |
| V4.4 | 2026-08-19 | UART2 DMA 下 BLE→Loop OTA 修复: lup_feed_byte_ota 0x9F 帧解析+透传回 BLE (停等 ACK), DMA poll OTA 模式切换/溢出阈值/tick 归零, 单测 8 例 |
| V4.3 | 2026-08-18 | log_query 改 hex 原始字节上报 (协议 V1.03/V1.07 修订): offlog_evt_to_hex/snap_rec_to_hex, 2 条快照 406B<800B, 快照 count 恢复 2 |
| V4.2 | 2026-08-18 | MQTT 命令集补齐: ssc_net_query/iot_net_query/iot_topic_query 只读组包 (协议 V1.02 对齐), 工具禁用未实现按钮 |