18 Commits
Author SHA1 Message Date
wangfq 572077031c docs(vd_960): V1.02.05 发布文档同步 — 产品手册/技术规格书 V1.02→V1.03 + 修订记录
产品手册 V1.03 (2026-08-21, 配套整机 V1.02.05):
- 头部文档版本/配套固件 → V1.03 / Loop 1.0 + DBN 1.02.05
- 核心特性补: 网络上报携带地感版本 (MQTT loop_version_query) + 联网稳定性加固
- §6 固件升级配套: DBN 1.02.04 → 1.02.05
- §7 故障排查: 配置保存建议升至 V1.02.05 及以上 (保留 V1.02.04 已修复历史说明)
- §8 相关文档表: MQTT 协议 V1.07 → V1.10 (修正滞后)
- 修订记录 V1.03 行

技术规格书 V1.03 (2026-08-21, 配套整机 V1.02.05):
- 头部文档版本/配套 → V1.03 / V1.02.05
- §5.1 协议矩阵标题 → V1.02.05 配套
- §10 版本配套矩阵 → V1.02.05 / DBN 1.02.05
- §10.1 三段式 FIRMWARE_VER 1.02.04 → 1.02.05 + 新增 UART2 0x4A 版本查询总线时序约束
- 修订记录 V1.03 行

README 文档索引: 产品手册/技术规格书 V1.02 → V1.03
2026-08-21 09:07:40 +08:00
wangfq 6e711e35ef release(vd960DBN): 固件版本 1.02.04 → 1.02.05 (2026-08-21)
涵盖 8-20 联网稳定性修复系列 + 8-21 MQTT 协议 V1.10 实现:
- SocketSend 0x11 退避 / SocketCreat 失败中止 (死循环闭环)
- OTA hex 提取兼容 json.dumps 空格
- OTA 刷写状态回 idle + ota_report 主动上报
- OTA 命令缓冲 union 合并 (RAM 90% 复位闭环)
- MQTT V1.10: dev_info_query/initialize 补 loop_ver/loop_hw_ver + loop_version_query 实时查询

cmcng.h 三段式一致: FIRMWARE_VER="1.02.05" + MAIN=1/SUB=2/SUBSUB=5
README 当前发布行 + 子项目版本表 → 1.02.05
CHANGELOG 新增 V1.02.05 条目 (配套矩阵: MQTT V1.07→V1.10)
devlog V4.8 条目补版本说明

产品手册/技术规格书留待打 tag 正式发布时同步
2026-08-21 08:55:01 +08:00
wangfq 181467b9e6 feat(vd960DBN): MQTT 协议 V1.10 固件实现 — 网络上报携带地感版本 (2026-08-21)
协议 V1.10 (3be9e50) 三处新增落地:
1. dev_info_query 响应 data 补 loop_ver/loop_hw_ver (0x4A 缓存值, 未完成/失败为空串)
2. initialize 上报同样携带 (尽力, 可为空)
3. 新增 §4.25 loop_version_query: 0x4A 异步实时查询, 响应在 iot_verq_poll() 回包

固件:
- loop_uart_proto.h/c: 新增 LoopVerCache + g_lup_ver_cache (0x4A 缓存);
  lup_cache_from_info() 写缓存, lup_refresh_version_cache() 后台刷新 (通道空闲才发)
- iot_mqtt_srv.c: dev_info_query/initialize 加字段; loop_version_query 分支
  (暂存 msg_id/deadline 300ms, busy 拒绝重复查询); iot_verq_poll() 异步回包
  (RESPONSE_READY -> code=0 + 三字段 + 同步刷缓存; TIMEOUT -> code=5 保缓存值);
  后台刷新: 上电 iot_mqtt_init + OTA done (iot_ota_report_result ok=1) 置 dirty

关键设计: 版本来源=0x4A 实时查询 (非 OTA 元数据 version=目标版本);
缓存值语义 dev_info_query/initialize 同步回包不等异步; 与 TCP 共用 g_lup_cmd
单命令通道 (后发覆盖, 先发者超时 — 低频命令, 待板上验证时序)

单测: tests/test_iot_loop_ver.c — gcc 隔离 (extract+embed 6 函数 + mock)
44 断言全过: 版本帧解析(正常+4 错误路径) / 缓存写入+格式化(空串/NULL 防护) /
verq_poll 成功回包(code=0+三字段+topic+缓存刷新+通道释放) / 超时回包(code=5+缓存值) /
后台刷新(发起/忙不消费/响应消费/残留超时清理)

验证: check_c_balance.py 3 文件 OK; 待板上验证 UART2 0x4A 与 loop_data 共用总线时序
2026-08-21 08:26:11 +08:00
wangfq 3be9e504da docs(vd960DBN): MQTT 协议 V1.10 — 网络上报携带地感版本 (配合远程 OTA 版本核对)
需求: 能通过网络 OTA 地感固件后, 网络上报设备信息需带地感固件版本号,
供平台升级前后版本核对/归档。

现状基础 (复用, 零新协议原语):
- Loop 0x4A 版本查询已实现 (lup_build_get_version/lup_parse_version)
- TCP JSON 已有 loop_version_query 先例 (tcp_json_srv.c)
- MQTT 回包 topic 固定 dld960/{sn}/dev → 异步回包只需存 msg_id

协议 V1.10 变更:
- §4.2 dev_info_query 响应 + §5.1 initialize 上报: 新增 loop_ver/
  loop_hw_ver (地感固件/硬件版本, 主.次.次, 0x4A 缓存, 尽力携带可为空)
- 新增 §4.25 loop_version_query (srv→dev): 实时查询, 设备经 UART2
  0x4A 异步查询后回包 (loop_ver/loop_hw_ver/version_str), 超时回 code=5
- §3 命令表补 loop_version_query (对应串口 0x4A)
- 版本语义区分: soft_ver=整机 DBN 固件(主.次), loop_ver=地感 Loop 固件(主.次.次)
- 修订记录 V1.10; 设计稿/README/技术规格书同步; 工具 sample initialize 补字段
2026-08-20 22:18:08 +08:00
wangfq 51298693da fix(vd960DBN): OTA 刷写成功误判未启动 — 状态回 idle + 补 ota_report 主动上报
现场: 远程平台发起 OTA, 设备物理刷写成功 (70块全ACK, flash DONE),
但平台 ota_status 轮询只见 state=ready → 误判'刷写未启动'。

根因:
1. ota_flash_done 刷写成功后台侧状态置 READY (与协议状态机
   'FLASHING──成功──▶IDLE(清槽/保留)' 不符), 平台无法区分
   '待刷 ready' 与 '已刷完' → 持续 ready 判未启动
2. 固件漏实现协议 §5.5 ota_report 主动上报 (done/failed 重发 3次×5s),
   平台收不到成功信号, 只能靠 ota_status 轮询兜底

修复 (协议 V1.09 + 固件):
- 协议: 刷写成功状态明确回 idle (镜像保留, last_result=0, 可重刷);
  ota_report 升级为刷写结果主依据; 平台判定指引 (idle+size>0+last_result=0
  =成功; 不得以轮询未见 flashing 或持续 ready 判未启动)
- ota_srv.c: ota_flash_done → OTA_STATE_IDLE (镜像保留) + 上报 done;
  ota_flash_fail → 补 ota_report failed (保留 event_report 告警);
  ota_cmd_begin 兼容 idle+size/crc32 一致 → 直接回 ready (免下载重刷)
- iot_mqtt_srv.c: 新增 ota_report 上报状态机 (立即首发 + 5s×3 重发,
  同 msg_id/ts), 共享 _iot_pub_payload (event_report 复用, RAM 零新增)
- 单测: test_flash_flow/test_flash_ready_timeout 断言 ota_report 上报,
  state 断言 READY→IDLE; 新增 test_begin_reflash; 10/10 全过 + 22 py 断言
2026-08-20 18:56:21 +08:00
wangfq 1badba3893 fix(vd960DBN): 联网 SocketSend 0x11 死循环 — 发送退避 + SocketCreat 失败中止
现象: loop_data 606B 发送 0x11(ERR_MEM) → 立即重试10次 → 3次断连 →
重连 SocketCreat 0x1D(ISCONN) 仍继续 Connect → timeout 死循环永久失联

根因:
1. WCHNET_NUM_TCP_SEG=2 (瘦身改小): 重传队列占用即无发送缓冲 → 0x11 瞬时
2. iot_mqtt_send 失败处理过激: 无退避立即重试 + 3次就强制断连
3. WCHNET_CreateTcpMqttSocket: SocketCreat 失败(mStopIfError只打印)仍继续
   Connect → 无效 socket 等超时死循环; SocketId_TCP 未置 0xFF 沿用旧 id

修复:
- iot_mqtt_send: 0x11 200ms 退避重试(≤10次, 等SEG释放), 不立即断连
- 强制重连: 补 SocketId_TCP=0xFF
- WCHNET_CreateTcpMqttSocket: SocketCreat 失败置 0xFF + return 不 Connect,
  上层指数退避

验证: 语法 0 错误; 待板级确认重发自动恢复 + 重连退避
2026-08-20 18:16:31 +08:00
wangfq c305ca651f fix(vd960DBN): OTA 下载失败 — hex 提取兼容 json.dumps 空格
现象: DBNMQTTool 第一片 ota_data code=1 crc/gap, received=0, 重试全败

根因: 工具 json.dumps 默认带空格 ("data": "hex"), 设备端 strstr 找
"data":" 无空格格式 → 提取失败 → hexbuf 空 → 解码失败 code=1
(simple_parse_json 容错空格所以其他命令正常, 只有手工 strstr 踩坑)

修复:
- ota_srv.c/h: 新增 ota_json_extract_hex() 兼容有无空格 (扫描 data 键跳过空白)
- iot_mqtt_srv.c: ota_data 改用该函数, 删 strstr 手工提取 + hp 残留
- 单测 +test_json_extract_hex: 无空格/带空格(事故场景)/512hex/找不到 4 场景

验证: 单测 11/11 PASS; 语法 0 错误; 待板级重测下载
2026-08-20 14:13:06 +08:00
wangfq 2484a03326 fix(vd960DBN): 联网复位事故 — OTA 命令缓冲合并 union (RAM 90% 栈余量不足)
现象: SUBACK 后收平台 report_config (PUBLISH len=197) → HardFault →
NVIC_SystemReset (RST_REASON 0x10000000 = SFT, 非 IWDG) → 死循环复位

根因: RAM 90.02% + 6 个 OTA 分支独立 static resp[256~400] + hexbuf[513]
共 ~2.5KB BSS → 栈余量被挤 → iot_handle_publish 深调用链栈溢出
(局部栈改 static 是伪优化: BSS↑=栈余量↓; 正解是 union 复用减总量)

修复:
- iot_mqtt_srv.c: 6 resp + hexbuf 合并函数级 static union _ota_io (~2KB 省)
- ota_srv.c: _chunk_buf/_fs_frame 合并 union (~260B 省) + 删无用变量

验证: 语法 0 错误; gcc 隔离单测 10/10; 待板级确认 RAM 回 ~88% 不再复位
2026-08-20 14:04:00 +08:00
wangfq 0b51d84a9b perf(vd960DBN): OTA RAM 优化 — union 复用块缓冲 + 命令分支数组改 static
MRS 编译 RAM 90.02% (44248/48KB) 逼近历史 .bss 挤栈红线, 减负:
- ota_srv.c: _chunk_buf[256] + _fs_frame[254] 合并为 union (下载/校验与刷写帧
  互斥复用, 省 ~260B static); ota_send_9f 组帧直接用 union 缓冲 (省 254B 栈)
- iot_mqtt_srv.c: 6 个 ota_* 分支 resp[256~400] + ota_data hexbuf[513] 局部栈
  数组改 static (栈峰值 → BSS, 防运行时栈溢出; RAM 总量不变但运行时安全)

验证: gcc 隔离单测 9/9 全过; 语法 0 新增错误
2026-08-20 13:41:33 +08:00
wangfq 49736c341c feat(vd960DBN): Loop MCU 远程 OTA 固件实现 (MQTT V1.08, ROADMAP P1.4 ①)
先存后刷: MQTT 分片下载 → W25Qxx OTA 暂存区 (0x010000 512KB) → 全镜像 CRC32
复核 → 本地 0x9F ISP 透传刷写 Loop MCU (AT32F421)

- 新增 ota_srv.c/h: CRC32(ISO-HDLC 逐位零表)/OtaMeta 双备份(节流 4KB flush)/
  会话状态机(begin/data/end/abort/flash)/本地刷写状态机(非阻塞 tick: A5→A6→A7
  停等 ACK 1s×3)/安全窗口(有车拒绝 force 跳过)/失败 event_report ota_error
- iot_mqtt_srv.c: 6 个 ota_* 命令分发 + 会话静默(event_report 积压/offlog·快照
  落盘暂停, 结束补发) + IOT_EVT_OTA_ERROR + iot_any_car
- usart_biz.c: uart_srv OTA 分支 ACK 分发 (本地刷写→ota_flash_feed_ack, BLE 透传不变)
- peripheral_main.c: ota_init + ota_poll 挂主循环
- offlog: OTA_START 0x60/OTA_RESULT 0x61 审计事件
- tests/test_ota_srv.c: gcc 隔离单测 9 组全过 (mock NOR/UART2/时钟, 嵌入真实实现)
  抓到 2 个真 bug: ①A7 序号未递减(bootloader 等不到末包) ②重发时 retry 清零(超时永不失败)
- 语法检查: ota_srv/iot_mqtt_srv/usart_biz/offlog 0 新增错误 (基线 interrupt 假阳性除外)
- devlog 置顶条目 + 待板上验证清单
2026-08-20 12:09:13 +08:00
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
29 changed files with 4345 additions and 65 deletions
+60
View File
@@ -5,6 +5,66 @@
---
## vd960DBN V1.02.05 — 2026-08-21DBN 通信板单侧更新)
> 本次为 **vd960DBN 单侧发布**(Loop 固件未变,可正常配套)。涵盖 2026-08-20 联网稳定性修复系列 + 2026-08-21 MQTT 协议 V1.10 实现。
**配套版本矩阵**
| 组件 | 版本 |
|------|------|
| vd960DBN 固件 (CH32V208, DLD960GA) | **1.02.05** |
| vd960Loop 固件 (AT32F421) | 1.0(不变) |
| DLD960Loop 串口协议(0x7F / 0x9F OTA | V1.05(不变) |
| DLD960 TCP JSON 协议(:5960 | V1.03(不变) |
| DLD960 IoT MQTT 协议 | **V1.10** |
| DLD960 BLE 协议 | V1.02(不变) |
### 🆕 MQTT 协议 V1.10:网络上报携带地感版本(配合远程 OTA 版本核对)
- `dev_info_query` 响应 / `initialize` 上报 data 新增 **`loop_ver`**(地感固件 `"主.次.次"`+ **`loop_hw_ver`**(地感硬件)——来自 Loop `0x4A` 查询缓存(上电自动查一次 + OTA done 后刷新),查询未完成/失败为空串
- 新增 §4.25 **`loop_version_query`** 命令:平台实时查询 Loop 版本(0x4A 异步 → 回包 `loop_ver`/`loop_hw_ver`/`version_str`),超时回 `code=5`
- 固件实现:`loop_uart_proto.c/h` 新增 `LoopVerCache` 版本缓存 + `lup_cache_from_info()`/`lup_refresh_version_cache()``iot_mqtt_srv.c` 新增 `iot_verq_poll()` 异步回包 + 后台刷新
- **单测 44 断言全过**(版本解析/缓存/异步回包时序,`tests/test_iot_loop_ver.c`
### 🔧 联网稳定性修复(2026-08-20 系列)
- **SocketSend 0x11 死循环**WCHNET `WCHNET_ERR_MEM(0x11)` 为瞬时发送缓冲不足(`WCHNET_NUM_TCP_SEG=2`)——改为 200ms 退避重试(≤10 次),不再 3 次强制断连;`SocketCreat` 失败置 `SocketId_TCP=0xFF` + 中止 Connect(不再 timeout 死循环)
- **OTA 下载失败**`ota_json_extract_hex()` 兼容 `json.dumps` 空格(`"data": "..."`),单片 CRC 全错根因闭环
- **OTA 刷写状态语义**:刷写成功状态回 `idle`(镜像保留可重刷)+ 补 `ota_report done/failed` 主动上报(平台判定主依据)
- **联网复位事故**:OTA 命令缓冲合并 unionRAM 90% 栈余量不足 → HardFault 复位闭环)
### 📋 已知约束
- UART2 0x4A 版本查询与 `loop_data`/事件上报共用总线(低频命令,理论无冲突)——**待板上验证时序**
- OTA done 后缓存刷新存在 Loop 复位时间窗(查询可能超时,可接受;平台可用 `loop_version_query` 主动核对)
- BLE→Loop OTA 透传模式无自动退出(V1.02.03 遗留,升级后需断电重启)
### 验证
- ✅ gcc 隔离单测:`test_iot_loop_ver.c` 44 例(V1.10+ 既有 ota/offlog/snapshot/mqtt 单测全过
- [ ] 待板级回归:MQTT 联网长稳、`loop_version_query` 实际回包、OTA 全流程
---
## 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": "空闲(镜像保留,已刷写完成则 idle+size>0)",
"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 修订)
### 背景
+327 -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)))
# ================================================================
# 模拟设备上报
# ================================================================
@@ -571,6 +881,8 @@ class MainWindow(QMainWindow):
"model": "DLD960",
"hard_ver": "1.0",
"soft_ver": "1.0",
"loop_ver": "1.2.3",
"loop_hw_ver": "1.0.0",
"extra_info": {
"code": "869756049404948",
"csq": "21",
@@ -946,7 +1258,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 +1302,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)
+5 -5
View File
@@ -1,6 +1,6 @@
# vd_960 — DLD960 四通道车辆检测器(双 MCU 架构)
> 当前发布:**vd960DBN V1.02.03**2026-08-19DBN 单侧更新)· 完整版本见 [CHANGELOG.md](CHANGELOG.md)
> 当前发布:**vd960DBN V1.02.05**2026-08-21,DBN 单侧更新)· 完整版本见 [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.05 | 蓝牙管理、TCP/IPWCHNET)、MQTT、串口桥接、4G 扩展(RFU) |
> ⚠ 发布约束:自协议 V1.05 起(variation 2B无符号 → 3B有符号),**Loop 固件与 DBN 固件必须同版本配套发布**,禁止混刷。
@@ -49,8 +49,8 @@ DLD960 是一款基于环形线圈检测原理的四通道车辆检测器,支
| 文档 | 版本 | 说明 |
|------|------|------|
| [DLD960_产品手册.md](docs/DLD960_产品手册.md) | V1.02 | 面向安装调试与使用:接口、指示灯、线圈施工、配置、故障排查 |
| [DLD960_技术规格书.md](docs/DLD960_技术规格书.md) | V1.02 | 完整技术参数:检测规格、灵敏度、算法机制、接口、协议矩阵 |
| [DLD960_产品手册.md](docs/DLD960_产品手册.md) | V1.03 | 面向安装调试与使用:接口、指示灯、线圈施工、配置、故障排查 |
| [DLD960_技术规格书.md](docs/DLD960_技术规格书.md) | V1.03 | 完整技术参数:检测规格、灵敏度、算法机制、接口、协议矩阵 |
## 协议文档(docs/
@@ -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.10 | 云平台 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 分配、继电器、指示灯、拨码 |
+368 -4
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,17 @@ 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 | — |
| `loop_version_query` | 实时查询地感 Loop MCU 版本(V1.10 | srv→dev | 0x4A |
| `initialize` | 设备上电初始化登陆 | dev→srv | — |
| `loop_data` | 线圈传感数据上报 | dev→srv | 0xC0 |
| `event_report` | 事件上报(**平台须应答**,见 §5.3 | dev→srv | — |
| `ota_report` | OTA 进度/结果主动上报 | dev→srv | — |
| `heartbeat` | 设备心跳 | dev→srv | — |
---
@@ -205,6 +226,8 @@ dld960/{dev_serial}/{direction}
"dev_serial": "A1B2C3D4E5F6",
"hard_ver": "1.1",
"soft_ver": "1.1",
"loop_ver": "1.2.3",
"loop_hw_ver": "1.0.0",
"model": "DLD960",
"product_code": "960001",
"sub_code": {
@@ -224,14 +247,18 @@ dld960/{dev_serial}/{direction}
| data 字段 | 类型 | 说明 |
|-----------|------|------|
| `dev_serial` | string | 12 位十六进制序列码 |
| `hard_ver` | string | 硬件版本,格式 `"主.次"` |
| `soft_ver` | string | 软件版本,格式 `"主.次"` |
| `hard_ver` | string | 硬件版本(整机),格式 `"主.次"` |
| `soft_ver` | string | 软件版本(整机 DBN MCU 固件),格式 `"主.次"` |
| `loop_ver` | string | **地感 Loop MCU 固件版本**V1.10),格式 `"主.次.次"`(如 `1.2.3`);来自 Loop 0x4A 查询缓存,**查询未完成/失败则为空字符串** |
| `loop_hw_ver` | string | **地感 Loop MCU 硬件版本**V1.10),格式 `"主.次.次"`;同上,可为空 |
| `model` | string | 产品型号,1~10 字符 |
| `product_code` | string | 产品编码,6 位数字字符串 |
| `sub_code.net` | bool | 网络功能是否启用 |
| `sub_code.iot` | bool | IoT/MQTT 功能是否启用 |
| `bus.bus1~4` | uint8 | 各总线探头数 |
> **loop_ver 语义(V1.10**`loop_ver`/`loop_hw_ver` 是**缓存值**(设备上电后自动经 Loop `0x4A` 查询一次,OTA 刷写成功后刷新);MQTT 命令处理为同步回包,不等 0x4A 异步响应,故查询未完成/失败时回空。平台需最新版本时用 `loop_version_query`(§4.25)实时查询。
## 4.3 设置 SSC 网络配置 `ssc_net_set`
> Topic: `dld960/{sn}/srv`
@@ -798,6 +825,298 @@ 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}` 告警(平台必答)
5. **刷写结果(设备侧,V1.09 明确)**
- 成功(末块 ACK)→ 元数据 `state=idle`**镜像保留**`size`/`crc32`/`version`/`slot` 不变,`last_result=0``flash_cnt+1`)→ 立即上报 `ota_report{stage:done}`(重发 3 次×5s,同 `msg_id`/`ts`)→ 恢复 `event_report` 发送与 offlog/快照落盘
- 失败 → 元数据 `state=flash_failed` + `ota_report{stage:failed}` + `event_report{type:ota_error}`done/failed 同重发策略)
- **状态语义**:刷写完成后 DBN 侧状态回 `idle`("本轮刷写已结束"),与"下载完成待刷(`ready`)"严格区分——**平台不得把 `state=ready` 判为"刷写未启动"**:本地刷写 70 块(17224B)实测 <1s,平台轮询间隔可能错过 `flashing` 中间态
**响应:** `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`**`idle``size>0` = 已刷写完成(镜像保留可重刷)** |
| `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 ──成功──▶ IDLE(镜像保留, last_result=0)
└──失败×3──▶ FLASH_FAILED ──ota_abort/ota_begin──▶ IDLE
```
**平台判定指引(V1.09):**
- **主依据**`ota_report` 主动上报(`stage=done` 成功 / `stage=failed` 失败,§5.5)——`ota_flash` 后平台应优先等该信号
- **兜底**`ota_status` 查询——`state=idle 且 size>0 且 last_result=0` = 刷写成功(镜像保留,可重刷);`state=flash_failed` = 失败;`state=ready` = 下载完成待刷(**不是**"刷写未启动"
- 刷写窗口极短(<1s),轮询可能捕捉不到 `flashing` 中间态,不得以此判失败
---
## 4.25 查询地感版本 `loop_version_query`
> Topic: `dld960/{sn}/srv`
> 用途:实时查询地感 Loop MCU 固件/硬件版本(V1.10,配合远程 OTA 做升级前后版本核对)。
**请求:**
```json
{ "msg_id": 407, "cmd": "loop_version_query", "ts": 1719000007 }
```
**设备行为(异步):**
1. 经 UART2 向 Loop MCU 发 `0x4A` 查询帧(`7F 00 01 4A ...`,见 `DLD960Loop_串口通信协议.md` §3.01
2. 收到 Loop 响应后解析(Soft/Hard 各三段)→ 更新本地缓存 → 立即回包
3. Loop 无响应/超时 → 回 `code=5``loop_ver` 保持缓存值(可为空)
**响应:**
```json
{
"msg_id": 407,
"cmd": "loop_version_query",
"ts": 1719000008,
"code": 0,
"msg": "success",
"data": {
"loop_ver": "1.2.3",
"loop_hw_ver": "1.0.0",
"version_str": "V1.2.3 (HW:1.0.0)"
}
}
```
| data 字段 | 类型 | 说明 |
|-----------|------|------|
| `loop_ver` | string | 地感固件版本,格式 `"主.次.次"`Soft_Main.Sub.SSub |
| `loop_hw_ver` | string | 地感硬件版本,格式 `"主.次.次"`Hard_Main.Sub.SSub |
| `version_str` | string | 人类可读串,`"V{soft} (HW:{hard})"`Loop 无响应时可能为空 |
> **时序与缓存**:设备上电后自动查询一次并缓存;`ota_report stage=done`(Loop 复位跑新固件)后自动刷新缓存。平台升级流程建议:`ota_flash``loop_version_query` 记录旧版本 → 刷写完成后再次查询核对新版本是否生效。
---
# 5 设备主动上报
## 5.1 设备上电登陆信息 `initialize`
@@ -814,6 +1133,8 @@ dld960/{dev_serial}/{direction}
"model": "DLD960",
"hard_ver": "1.0",
"soft_ver": "1.0",
"loop_ver": "1.2.3",
"loop_hw_ver": "1.0.0",
"extra_info": {
"code": "869756049404948",
"csq": "21",
@@ -827,12 +1148,16 @@ dld960/{dev_serial}/{direction}
|------|------|------|
| `data.dev_serial` | string | 设备序列码 |
| `data.model` | string | 产品型号 |
| `data.hard_ver` | string | 硬件版本 |
| `data.soft_ver` | string | 固件版本 |
| `data.hard_ver` | string | 硬件版本(整机) |
| `data.soft_ver` | string | 固件版本(整机 DBN MCU |
| `data.loop_ver` | string | **地感 Loop MCU 固件版本**V1.10),格式 `"主.次.次"`;来自 0x4A 缓存,查询未完成/失败则为空字符串 |
| `data.loop_hw_ver` | string | **地感 Loop MCU 硬件版本**V1.10),格式 `"主.次.次"`;同上可为空 |
| `data.extra_info.code` | string | 可选,设备代码如IMSI/ICCID |
| `data.extra_info.csq` | string | 可选,当前信号强度 |
| `data.extra_info.location` | string | 可选,经纬度(经度,纬度) |
> **V1.10 说明**`loop_ver`/`loop_hw_ver` 为**尽力携带**(上电后异步 0x4A 查询,initialize 发出时可能未就绪 → 回空);平台核对版本请用 `loop_version_query`(§4.25)。
## 5.2 线圈传感数据 `loop_data`
@@ -967,6 +1292,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 +1366,41 @@ 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.09 明确)**`ota_report` 是刷写结果的**主依据**——收到 `stage=done` 判成功、`stage=failed` 判失败,无需再轮询 `ota_status`。设备 `ota_flash` 响应 `code=0` 仅表示已启动(异步),**不得以"轮询 `ota_status` 未见 `flashing`"或"状态持续为 `ready`"判"刷写未启动"**——本地刷写 <1s 完成,轮询大概率错过中间态;兜底判定见 §4.24。
**会话期间静默**(V1.08):OTA 会话期间(`ota_begin` ~ 结束)设备暂停 `event_report` 发送(入队积压,结束后补发)与 offlog/快照落盘("升级开始"日志暂停前写入、"升级结果"恢复后补记),保证刷写窗口内 MQTT 保活与 IWDG 喂狗不受影响。
---
# 修订记录
@@ -1054,4 +1415,7 @@ 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.10 | 2026-08-20 | **网络上报携带地感版本**(配合远程 OTA 升级前后版本核对):`dev_info_query` 响应 + `initialize` 上报新增 `loop_ver`/`loop_hw_ver`(地感 Loop MCU 固件/硬件版本,格式 `"主.次.次"`,来自 0x4A 查询缓存,尽力携带可为空);新增命令 `loop_version_query`(§4.25,srv→dev 实时查询,设备经 UART2 0x4A 异步查询后回包,含 `loop_ver`/`loop_hw_ver`/`version_str`);版本语义:`soft_ver`=整机 DBN 固件(`主.次`),`loop_ver`=地感 Loop 固件(`主.次.次`),二者区分 | wangfq |
| V1.09 | 2026-08-20 | **OTA 刷写结果判定修复**(现场:刷写物理成功但平台误判"刷写未启动"):① 刷写成功后台侧状态回 `idle`(镜像保留:`size`/`crc32`/`version` 不变,`last_result=0`,可重刷),与"下载完成待刷 `ready`"严格区分;② `ota_report` 升级为刷写结果**主依据**`stage=done`/`failed` 设备必报,重发 3 次×5s),`ota_status` 仅兜底;③ 明确平台判定指引——`idle+size>0+last_result=0`=成功,不得以轮询未见 `flashing` 或状态持续 `ready` 判"刷写未启动"(本地刷写 <1s,轮询大概率错过中间态);④ `ota_begin` 兼容 `idle+size/crc32 一致` → 免下载直接可刷(重刷) | 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 |
+516
View File
@@ -0,0 +1,516 @@
# 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` 为准)。
**刷写结果(V1.09 明确):**
- 成功(末块 ACK)→ 元数据 `state=idle`**镜像保留**`size`/`crc32`/`version`/`slot` 不变,`last_result=0``flash_cnt+1`)→ 上报 `ota_report{stage:done}`(重发 3 次×5s)→ 恢复 event_report/offlog 落盘
- 失败 → `state=flash_failed` + `ota_report{stage:failed}` + `event_report{type:ota_error}`
- **状态语义**:刷写完成后回 `idle`("本轮刷写已结束"),与"下载完成待刷 `ready`"严格区分——平台**不得把 `state=ready` 判为"刷写未启动"**:本地刷写 <1s,轮询大概率错过 `flashing` 中间态;平台判定以 `ota_report done/failed` 为主依据,`ota_status` 兜底(`idle+size>0+last_result=0`=成功)
## 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 ──成功──▶ IDLE(镜像保留, last_result=0)
└──失败×3──▶ FLASH_FAILED ──ota_abort/ota_begin──▶ IDLE
```
**平台判定(V1.09)**:刷写结果以 `ota_report``stage=done`/`failed`)为主依据;`ota_status` 兜底——`state=idle 且 size>0 且 last_result=0` = 成功(镜像保留可重刷);`state=ready` = 待刷(**不是**"刷写未启动");轮询可能错过 `flashing` 中间态,不得以此判失败。
---
# 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 同策略)。
**平台判定(V1.09 明确)**`ota_report` 是刷写结果**主依据**——`stage=done` 判成功、`stage=failed` 判失败,无需轮询 `ota_status``ota_flash` 响应 `code=0` 仅表示已启动(异步),**不得以"轮询未见 `flashing`"或"状态持续 `ready`"判"刷写未启动"**(本地刷写 <1s,轮询大概率错过中间态);兜底判定见 §5.6。
## 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 约定 + 修订记录;**V1.10 补充版本核对**:`dev_info_query`/`initialize``loop_ver`/`loop_hw_ver`,新增 `loop_version_query`(§4.25)供平台升级前后核对地感版本 |
| `DLD960_TCP_JSON协议.md` | 本版**不扩展**ROADMAP 先走 MQTT);后续按"复用命令 + stream/字段"模式补(与 log_* 扩展同套路)。注:TCP 侧 `loop_version_query` 代码已有(`tcp_json_srv.c`),文档待同步 |
| `DLD960_BLE协议.md` | 不变(BLE OTA 维持流式透传现状;本地刷写状态机与 BLE 透传共用 `lup_feed_byte_ota` |
| `README.md` / `DLD960_技术规格书.md` | 协议矩阵同步 V1.10 |
**固件代码落点(协议拍板后动):**
- `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 喂狗)不受影响 |
+8 -4
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.03 · 2026-08-21 · 适用固件:整机 V1.02.05Loop 1.0 + DBN 1.02.05
---
@@ -26,6 +26,8 @@ DLD960 是一款四通道环形线圈车辆检测器,一台设备即可覆盖
- 以太网双协议:TCP JSON(局域网对接)+ MQTT(云平台),关键事件确认送达
- 通信可靠性加固:串口硬件 DMA 接收 + 固件内存优化,长时间运行稳定不丢帧、不异常重启
- BLE OTA 升级链路加固:检测 MCU 升级响应帧(0x9F 协议)完整透传回小程序,停等 ACK 不再丢失,升级成功率提升
- 网络上报携带地感版本:云平台可实时核对地感固件/硬件版本(MQTT `loop_version_query`,配合远程 OTA 升级前后版本核对)
- 联网稳定性加固:MQTT 发送退避与断连重连修复,长时间联网不掉线、不死循环
## 2. 硬件说明
@@ -132,7 +134,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.05
>
> ⚠ **检测 MCU 升级完成后,若小程序无响应,请将设备断电重启**(DBN OTA 透传模式需重启恢复,V1.02.03 已知约束)。
@@ -148,6 +150,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.05 及以上版本 |
| 事件重复上报 | 平台未按协议应答 event_report | 平台须回显 msg_id 应答(先落库后应答) |
| 线圈断开告警 | 线圈开路/引线断裂 | 万用表量线圈回路电阻(正常几欧姆以内) |
@@ -167,7 +170,7 @@ DLD960 是一款四通道环形线圈车辆检测器,一台设备即可覆盖
| 《DLD960 技术规格书》 | 完整技术参数 |
| 《DLD960 串口通信协议》V1.01 | 串口 对接开发 |
| 《DLD960 TCP JSON 协议》V1.03 | 局域网对接开发 |
| 《DLD960 IoT MQTT 协议》V1.07 | 云平台对接开发 |
| 《DLD960 IoT MQTT 协议》V1.10 | 云平台对接开发(含 Loop 远程 OTA 与地感版本查询) |
| 《DLD960 BLE 协议》V1.02 | 蓝牙小程序/APP 对接开发(脱机日志与传感快照读写) |
| 《环路车辆检测器验收标准》 | 采购/部署验收 |
@@ -177,6 +180,7 @@ DLD960 是一款四通道环形线圈车辆检测器,一台设备即可覆盖
| 版本 | 修订时间 | 修订说明 | 修订人 |
|------|----------|----------|--------|
| V1.02 | 2026-08-19 | 配套整机发布 V1.02.03:BLE OTA 升级链路加固说明(检测 MCU 0x9F 响应帧透传回小程序,停等 ACK 完整);故障排查新增 OTA 失败条目;固件升级章节补断电重启提示 | wangfq |
| V1.03 | 2026-08-21 | 配套整机发布 V1.02.04→V1.02.05:核心特性补网络上报携带地感版本 + 联网稳定性加固;相关文档表 MQTT 协议 V1.07→V1.10(修正滞后);故障排查配置保存建议升至 V1.02.05 及以上 | wangfq |
| V1.02 | 2026-08-19 | 配套整机发布 V1.02.03→V1.02.04BLE 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 |
+9 -7
View File
@@ -1,7 +1,7 @@
# DLD960 四通道车辆检测器 技术规格书
> Technical Specification · 产品型号:**DLD960GA**
> 文档版本 V1.02 · 2026-08-19 · 配套整机发布 V1.02.03
> 文档版本 V1.03 · 2026-08-21 · 配套整机发布 V1.02.05
---
@@ -100,13 +100,13 @@ DLD960 是一款基于环形线圈(LC 振荡)检测原理的四通道车辆
| 外部存储 | SPI NOR Flash | 参数存储 + 日志存储 |
| 按键 | K1 多功能按键 | 复位等 |
### 5.1 协议矩阵(V1.02.03 配套)
### 5.1 协议矩阵(V1.02.05 配套)
| 协议 | 版本 | 通道 | 要点 |
|------|------|------|------|
| 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.10 | 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,19 +166,20 @@ DLD960 是一款基于环形线圈(LC 振荡)检测原理的四通道车辆
> ⚠ **自内部协议 V1.05 起,vd960Loop 与 vd960DBN 固件必须同版本配套刷写**variation 字段 2B→3B 帧格式变更,混刷导致数据静默错位)。
| 组件 | V1.02.03 配套版本 |
| 组件 | V1.02.05 配套版本 |
|------|-------------------|
| vd960Loop 固件 | 1.0(不变) |
| vd960DBN 固件 | 1.02.03 |
| vd960DBN 固件 | 1.02.05 |
### 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.05"`MAIN=1 / SUB=2 / SUBSUB=5);BLE 上报仅 MAIN/SUB 两字节(1.02),SUBSUB 只体现在字符串上报(MQTT/TCP JSON)。
- 设备无 RTC,时钟同步依赖平台下发(校准前 ts 为上电秒数)。
- CH32V208 RAM 预算:BLE 协议栈固定占用低 16KB(不可裁剪),用户可用 48KB;固件经 RAM 瘦身优化,栈余量约 6KB,现场升级务必使用 V1.02.01 及以上版本。
- 传感快照区延后 3s 初始化(开机 3s 内传感数据不落盘,设计接受)。
- UART2 RX DMA 占用 DMA1_Ch6(全工程唯一,无冲突)。
- **OTA 透传模式无自动退出机制**:进入 OTA 态(`g_flag_counter_ota.flag=1`)后需断电重启才恢复 0x7F 正常通信(V1.02.03 已知约束,升级完成后重启设备)。
- **UART2 0x4A 版本查询与 `loop_data`/事件上报共用总线**:低频命令理论无冲突,时序待板上验证(V1.02.05)。
---
@@ -186,6 +187,7 @@ DLD960 是一款基于环形线圈(LC 振荡)检测原理的四通道车辆
| 版本 | 修订时间 | 修订说明 | 修订人 |
|------|----------|----------|--------|
| V1.02 | 2026-08-19 | 配套整机发布 V1.02.03UART2 RX 0x9F OTA 透传机制说明(§2/§5/§7)、协议矩阵版本配套矩阵更新、已知约束补充 OTA 无自动退出 | wangfq |
| V1.03 | 2026-08-21 | 配套整机发布 V1.02.04→V1.02.05协议矩阵标题/版本配套矩阵/固件三段式版本同步;新增已知约束 UART2 0x4A 版本查询总线时序;MQTT V1.10 地感版本上报与 loop_version_query 说明 | 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.05"
#define HARDWARE_VER "1.0"
#define FIRMWARE_VER_MAIN 1
#define FIRMWARE_VER_SUB 2
#define FIRMWARE_VER_SUBSUB 3
#define FIRMWARE_VER_SUBSUB 5
#define HARDWARE_VER_MAIN 1
#define HARDWARE_VER_SUB 1
@@ -52,6 +52,9 @@ void iot_mqtt_poll(void); // 主循环轮询 (状态机 + 心跳 +
void iot_mqtt_handle_sock_int(uint8_t socketid, uint8_t intstat); // Socket 中断处理
void iot_mqtt_publish_sensor(void); // 推送传感器数据到 MQTT
void iot_evt_handle_ack(uint32_t msg_id, int code); // event_report 平台应答入口 (V1.04)
void iot_evt_report_ota_error(uint32_t err); // OTA 刷写失败告警入队 (V1.08, 走 event_report 闭环)
void iot_ota_report_result(uint8_t ok, uint32_t err); // OTA 刷写结果上报 ota_report (V1.09: ok=1 done / ok=0 failed)
uint8_t iot_any_car(void); // 任一通道有车 (ota_flash 安全窗口检查)
void iot_watchdog_init(void); // 初始化硬件 IWDG (无条件, 主循环卡死兜底)
void iot_watchdog_kick(void); // 喂硬件 IWDG (主循环调用)
@@ -137,6 +137,17 @@ typedef struct {
char version_str[32]; // 格式化字符串
} LUP_VersionInfo;
/* 版本缓存 (V1.10) — 0x4A 查询结果缓存, 供 MQTT dev_info_query/initialize 带缓存值 */
typedef struct {
uint8_t valid; /* 1=缓存有效 (查询成功过) */
uint8_t hard_main;
uint8_t hard_sub;
uint8_t hard_ssub;
uint8_t soft_main;
uint8_t soft_sub;
uint8_t soft_ssub;
} LoopVerCache;
/* 命令状态机 */
typedef enum {
LUP_STATE_IDLE = 0, // 空闲
@@ -160,6 +171,7 @@ typedef struct {
* Global State
*===========================================================================*/
extern LUP_CmdTracker g_lup_cmd;
extern LoopVerCache g_lup_ver_cache; /* V1.10: 0x4A 版本缓存 */
/*===========================================================================
* Public API
@@ -194,6 +206,10 @@ void lup_cmd_done(void);
int lup_cmd_check_timeout(void);
void lup_cmd_on_response(const uint8_t *pkg, uint16_t len);
/* --- 版本缓存 (V1.10) --- */
void lup_cache_from_info(const LUP_VersionInfo *info); /* 解析结果写入缓存 */
void lup_refresh_version_cache(void); /* 后台刷新: 命令通道空闲时发 0x4A */
/* --- High-level Commands (send + wait handled by state machine) --- */
void lup_send_get_version(void);
void lup_send_reset(void);
@@ -116,6 +116,8 @@ enum {
OFFLOG_EVT_EVT_GIVEUP = 0x31, /* event_report 重试耗尽挂起; payload[0..3]=msg_id */
OFFLOG_EVT_COIL = 0x40, /* 线圈事件; payload[0]=sub(1=进 2=出 3=断开 4=恢复), payload[1]=ch, payload[2..5]=value(50ms) */
OFFLOG_EVT_TIME_ANCHOR = 0x50, /* 时钟同步锚点; unix_ts=同步值 */
OFFLOG_EVT_OTA_START = 0x60, /* 固件升级开始 (V1.08); payload[0]=slot, payload[1]=target, payload[2..5]=size BE */
OFFLOG_EVT_OTA_RESULT = 0x61, /* 固件升级结果 (V1.08); payload[0..3]=result code BE (0=成功, 非0=OTA_ERR_*) */
OFFLOG_EVT_LOG_CLEAR = 0x70, /* 日志清除 (审计) */
};
@@ -150,6 +152,8 @@ void offlog_evt_retry(uint32_t msg_id, uint8_t retry);
void offlog_evt_giveup(uint32_t msg_id);
void offlog_coil(uint8_t sub, uint8_t ch, uint32_t value);
void offlog_time_anchor(uint32_t unix_ts);
void offlog_ota_start(uint8_t slot, uint32_t size); /* 固件升级开始 (V1.08, 会话静默前写入) */
void offlog_ota_result(uint32_t code); /* 固件升级结果 (V1.08, 恢复后补记) */
uint32_t offlog_boot_seq(void); /* 当前 boot 序号 */
uint16_t offlog_count(void); /* 有效记录数 */
@@ -0,0 +1,147 @@
/**
******************************************************************************
* @file ota_srv.h
* @author wangfq
* @version V1.0
* @date 2026-08-20
* @brief DLD960 Loop MCU OTA (MQTT V1.08, ROADMAP P1.4 )
*
* : MQTT W25Qxx (0x010000 512KB)
* CRC32 0x9F ISP Loop MCU
*
* : docs/DLD960_IoT_MQTT协议.md §4.19~4.24 / §5.5
* 稿: docs/DLD960_MQTT_OTA协议.md V1.01
******************************************************************************
*/
#ifndef __OTA_SRV_H__
#define __OTA_SRV_H__
#include <stdint.h>
/*===========================================================================
* ( offlog.h: OFFLOG_PARAM_SIZE=64KB + OFFLOG_OTA_SIZE=512KB)
* OTA = 0x010000 ~ 0x090000 (512KB)
* : (4KB) + Slot A(100KB) + Slot B(100KB) + (308KB)
*===========================================================================*/
#define OTA_AREA_BASE 0x10000UL /* 0x010000 (参数区 64KB 后) */
#define OTA_AREA_SIZE 0x80000UL /* 512KB */
#define OTA_META_BASE (OTA_AREA_BASE + 0x0000UL) /* 元数据主备份 */
#define OTA_META_BACKUP (OTA_AREA_BASE + 0x0200UL) /* 元数据备份 (同扇区) */
#define OTA_META_SECTOR (OTA_AREA_BASE / 4096UL) /* 元数据所在扇区号 */
#define OTA_SLOT_A_BASE (OTA_AREA_BASE + 0x1000UL) /* 0x011000 */
#define OTA_SLOT_B_BASE (OTA_AREA_BASE + 0x1A000UL) /* 0x02A000 */
#define OTA_SLOT_SIZE (100UL * 1024UL) /* 100KB */
#define OTA_MAX_IMAGE (96UL * 1024UL) /* 镜像上限 96KB (留 4KB 余量) */
/* 下载单片 (协议常量, 256B 页对齐) */
#define OTA_CHUNK_SIZE 256
/* 刷写块 (0x9F LEN 为 uint8 → DATA ≤ 254B; 取 248B) */
#define OTA_FLASH_BLOCK 248
/*===========================================================================
*
*===========================================================================*/
enum {
OTA_STATE_IDLE = 0, /* 空闲 */
OTA_STATE_DOWNLOADING, /* 下载中 */
OTA_STATE_READY, /* 校验通过, 可刷写 */
OTA_STATE_FLASHING, /* 刷写中 */
OTA_STATE_FLASH_FAILED, /* 刷写失败 (保留镜像可重试) */
OTA_STATE_ABORTED, /* 已中止 */
};
/* 刷写子状态 (非阻塞 tick 驱动) */
enum {
OTA_FLASH_IDLE = 0,
OTA_FLASH_START, /* 发 A5 启动帧 */
OTA_FLASH_WAIT_READY, /* 等 pre_ok */
OTA_FLASH_ADDR, /* 发 A6 地址帧 */
OTA_FLASH_WAIT_ADDR, /* 等 addr_ok */
OTA_FLASH_DATA, /* 发 A7 数据块 */
OTA_FLASH_WAIT_ACK, /* 等 data ACK */
};
/* OTA 错误码 (event_report type=ota_error 的 value, 协议 §5.3) */
#define OTA_ERR_READY_TIMEOUT 0x1001 /* 启动帧无响应 (pre_ok 超时) */
#define OTA_ERR_ADDR_FAIL 0x1002 /* 地址帧错误 (addr_err / 超时) */
#define OTA_ERR_DATA_ACK_LIMIT 0x1003 /* 数据块 ACK 超限 (重试×3 耗尽) */
#define OTA_ERR_CRC_FAIL 0x1004 /* 全镜像校验失败 */
#define OTA_ERR_FORCE_FAIL 0x1005 /* 安全窗口拒绝后强制失败 */
/*===========================================================================
* (56B, 64B )
*===========================================================================*/
typedef struct {
uint32_t magic; /* OTA_MAGIC 'DLD9' */
uint32_t state; /* OTA_STATE_* */
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; /* 上次刷写结果: 0=无/成功, 非0=错误码 */
uint32_t begin_ts; /* 会话开始 (同步后 Unix 秒, 0=未同步) */
char version[16]; /* 目标固件版本 (审计) */
uint32_t flash_cnt; /* 刷写尝试次数 */
uint32_t rsv2;
} OtaMeta; /* 56B */
#define OTA_MAGIC 0x39444C44UL /* 'DLD9' */
/*===========================================================================
* API
*===========================================================================*/
/* 初始化: 读元数据 (双备份校验), 刷写状态机复位 */
void ota_init(void);
/* 主循环轮询: 刷写状态机 (非阻塞 tick 驱动, 绝不阻塞主循环) */
void ota_poll(void);
/* OTA 会话期间静默标志: downloading/flashing 时置位
event_report () + offlog/ */
uint8_t ota_silent_active(void);
/* 当前会话元数据 (只读, MQTT 响应组装用) */
const OtaMeta *ota_meta(void);
/* 状态字符串 (JSON state 字段) */
const char *ota_state_str(uint8_t state);
/*===========================================================================
* MQTT (iot_mqtt_srv.c , )
* 0=, 0= (1= 3= 5=; data.err_code OTA_ERR_*)
*===========================================================================*/
int ota_cmd_begin(uint32_t size, uint32_t crc32, const char *version, uint8_t force);
int ota_cmd_data(uint32_t offset, uint32_t crc32, const char *hex);
int ota_cmd_end(uint32_t crc32);
int ota_cmd_abort(void);
int ota_cmd_flash(uint8_t slot, uint8_t force);
/* 分片落盘用: hex → bin (n 字节), 成功 0 */
int ota_hex_decode(const char *hex, uint8_t *out, uint32_t n);
/* 从 MQTT JSON 中提取 ota_data 的 data 字段 hex 值 (兼容 "data":"..." 与 "data": "...")
hex , 0=/ */
int ota_json_extract_hex(const char *json, char *out, int out_len);
/* CRC-32/ISO-HDLC (逐位实现, 零查表 RAM) */
uint32_t ota_crc32(const uint8_t *data, uint32_t len);
/*===========================================================================
* UART2 (usart_biz.c 使)
*===========================================================================*/
/* 1 = 本地刷写进行中: uart_srv OTA 分支把 0x9F ACK 交给 ota_flash_feed_ack,
BLE (extern ota_srv.c) */
extern uint8_t g_ota_flash_active;
/* usart_srv 收到 bootloader 0x9F ACK 帧时调用 (主循环上下文) */
void ota_flash_feed_ack(const uint8_t *pkg, uint16_t len);
/* 刷写进度 (已送字节 / 总量, ota_report 用) */
uint32_t ota_flash_sent(void);
uint32_t ota_flash_total(void);
#endif /* __OTA_SRV_H__ */
@@ -26,6 +26,7 @@
#include "offlog.h"
#include "snapshot.h"
#include "fault_diag.h"
#include "ota_srv.h"
#include "ch32v20x_iwdg.h"
#include <string.h>
#include <stdlib.h>
@@ -58,6 +59,10 @@ static LUP_SensorReport _cached_sr; // 最新传感器数据缓存
static uint8_t _cached_sr_valid; // 缓存有效标志
static uint32_t _last_publish_ms; // 上次上报时刻 (ms)
/* 上行发布共享缓冲 (V1.09): event_report / ota_report 复用, 互斥调用
(RAM 90% : , ) */
static char _iot_pub_payload[512];
/*===========================================================================
* event_report V1.04: +
*
@@ -73,7 +78,8 @@ static uint32_t _last_publish_ms; // 上次上报时刻 (ms)
#define IOT_EVT_ACK_TIMEOUT_MS 5000 // 确认超时 (协议 V1.04)
#define IOT_EVT_MAX_RETRY 3 // 最大重发次数 (含首发共4次)
enum { IOT_EVT_CAR_ENTER = 0, IOT_EVT_CAR_LEAVE, IOT_EVT_LOOP_CUT, IOT_EVT_LOOP_RESTORE };
enum { IOT_EVT_CAR_ENTER = 0, IOT_EVT_CAR_LEAVE, IOT_EVT_LOOP_CUT, IOT_EVT_LOOP_RESTORE,
IOT_EVT_OTA_ERROR }; /* V1.08: OTA 刷写失败告警 (value=OTA_ERR_*) */
typedef struct {
uint8_t type; // IOT_EVT_xxx
@@ -112,8 +118,10 @@ static void iot_evt_enqueue(uint8_t type, uint8_t ch, uint32_t value) {
IotEvent *e = &_evt_queue[(_evt_head + _evt_count) % IOT_EVT_QUEUE_DEPTH];
e->type = type; e->ch = ch; e->value = value;
_evt_count++;
/* 事件日志: 线圈事件 (主循环上下文) */
offlog_coil((uint8_t)type + 1, ch, value); /* sub: 1=进 2=出 3=断开 4=恢复 */
/* 事件日志: 线圈事件 (主循环上下文); OTA 会话期间暂停落盘 (V1.08) */
if (!ota_silent_active()) {
offlog_coil((uint8_t)type + 1, ch, value); /* sub: 1=进 2=出 3=断开 4=恢复 */
}
}
_evt_gaveup = 0; // 新事件到达 → 解除挂起, 触发合并上报
PRINT("EVT: enqueue type=%d ch=%d val=%lu (cnt=%d)\n", type, ch, value, _evt_count);
@@ -170,7 +178,9 @@ static void iot_sensor_ingest(const LUP_SensorReport *sr) {
iot_evt_feed(sr); // 事件沿检测
memcpy(&_cached_sr, sr, sizeof(LUP_SensorReport)); // 刷新 loop_data 快照
_cached_sr_valid = 1;
snap_enqueue(sr); /* 快照入队: 中断安全, 主循环 snap_flush 落盘 */
if (!ota_silent_active()) {
snap_enqueue(sr); /* 快照入队: 中断安全, 主循环 snap_flush 落盘; OTA 会话暂停 (V1.08) */
}
FAULT_MARKER(MK_INGEST_OUT);
}
@@ -187,12 +197,18 @@ static void iot_evt_sensor_cb(const uint8_t *pkg, uint16_t len) {
/* 序列化并发布队列头 n 条事件 (首发与重发共用: 同 id/ts 同内容) */
static void iot_evt_send(uint32_t id, uint32_t ts, uint8_t n) {
static const char *evt_names[] = {"car_enter", "car_leave", "loop_cut", "loop_restore"};
static char payload[512]; // static: 避免 2KB 栈溢出
int w, rem = sizeof(payload);
static const char *evt_names[] = {"car_enter", "car_leave", "loop_cut", "loop_restore", "ota_error"};
char *payload = _iot_pub_payload; // V1.09: 文件级共享缓冲
int w, rem = sizeof(_iot_pub_payload);
char *p = payload;
uint8_t i;
/* OTA 会话期间静默 (V1.08): 暂停发送, 事件保留队列, 结束后补发 */
if (ota_silent_active()) {
PRINT("EVT: silent (OTA), skip publish\n");
return;
}
w = snprintf(p, rem, "{\"msg_id\":%lu,\"cmd\":\"event_report\",\"ts\":%lu,"
"\"data\":{\"events\":[", id, ts);
if (w < 0 || w >= rem) return;
@@ -201,7 +217,7 @@ static void iot_evt_send(uint32_t id, uint32_t ts, uint8_t n) {
for (i = 0; i < n; i++) {
const IotEvent *e = &_evt_queue[(_evt_head + i) % IOT_EVT_QUEUE_DEPTH];
w = snprintf(p, rem, "%s{\"type\":\"%s\",\"ch\":%d,\"value\":%lu}",
(i > 0) ? "," : "", evt_names[e->type & 0x03], e->ch, e->value);
(i > 0) ? "," : "", evt_names[(e->type < 5) ? e->type : 0], e->ch, e->value);
if (w < 0 || w >= rem) return;
p += w; rem -= w;
}
@@ -230,6 +246,118 @@ void iot_evt_handle_ack(uint32_t msg_id, int code) {
_evt_retry = 0;
}
/* OTA 刷写失败告警入队 (V1.08): 走 event_report ACK 闭环, 平台必答
(ota_srv.c ota_flash_fail ; OTA_ERR_* ota_srv.h) */
void iot_evt_report_ota_error(uint32_t err)
{
iot_evt_enqueue(IOT_EVT_OTA_ERROR, 0, err);
PRINT("EVT: ota_error enqueue err=0x%lX\n", (unsigned long)err);
}
/*===========================================================================
* OTA ota_report V1.08 §5.5 / V1.09
*
* done/failed , ACK 3 ×5s ( msg_id/ts),
* (sn,msg_id) ( 10min) _iot_pub_payload
* iot_ota_report_result(): + ; iot_ota_report_poll():
* , 5s 3
*===========================================================================*/
#define IOT_OTA_REP_MAX_RETRY 3 /* 重发次数 (含首发共 4 次) */
#define IOT_OTA_REP_INTERVAL_MS 5000
static uint8_t _ota_rep_pend; /* 1=有未完成的 ota_report */
static uint8_t _ota_rep_ok; /* 1=done 0=failed */
static uint32_t _ota_rep_err; /* failed 的错误码 (OTA_ERR_*) */
static uint32_t _ota_rep_id; /* 固定 msg_id (重发不变) */
static uint32_t _ota_rep_ts; /* 固定 ts (重发不变) */
static uint8_t _ota_rep_times; /* 已发次数 (首发=1) */
static uint32_t _ota_rep_sent_ms; /* 上次发送时刻 */
/* V1.10: loop_version_query 异步查询 (0x4A) 状态 — 与 TCP 共用 g_lup_cmd 单命令通道 */
static uint8_t _iot_verq_pending; /* 1=有查询在途 (等 0x4A 响应回包) */
static uint32_t _iot_verq_msg_id; /* 命令 msg_id (回包回显) */
static uint32_t _iot_verq_deadline; /* 回包超时时刻 (ms), 与 TCP json_check_pending 一致 300ms */
static uint8_t _iot_ver_cache_dirty; /* 1=需后台刷新版本缓存 (上电 / OTA done 后置位) */
/* 版本缓存 → "x.y.z" 字符串 (缓存无效则空串) */
static void iot_loop_ver_str(char *out, uint16_t out_len)
{
if (!g_lup_ver_cache.valid || !out || out_len == 0) { if (out && out_len) out[0] = '\0'; return; }
snprintf(out, out_len, "%d.%d.%d",
g_lup_ver_cache.soft_main, g_lup_ver_cache.soft_sub, g_lup_ver_cache.soft_ssub);
}
static void iot_loop_hw_ver_str(char *out, uint16_t out_len)
{
if (!g_lup_ver_cache.valid || !out || out_len == 0) { if (out && out_len) out[0] = '\0'; return; }
snprintf(out, out_len, "%d.%d.%d",
g_lup_ver_cache.hard_main, g_lup_ver_cache.hard_sub, g_lup_ver_cache.hard_ssub);
}
/* 序列化并发布 ota_report (首发与重发共用: 同 id/ts 同内容) */
static void iot_ota_report_send(void)
{
char *p = _iot_pub_payload;
int w, rem = sizeof(_iot_pub_payload);
w = snprintf(p, rem,
"{\"msg_id\":%lu,\"cmd\":\"ota_report\",\"ts\":%lu,"
"\"data\":{\"target\":\"loop\",\"stage\":\"%s\","
"\"progress\":{\"sent\":%lu,\"total\":%lu},\"code\":%lu,\"msg\":\"\"}}",
_ota_rep_id, _ota_rep_ts,
_ota_rep_ok ? "done" : "failed",
(unsigned long)ota_flash_sent(), (unsigned long)ota_flash_total(),
_ota_rep_ok ? 0UL : (unsigned long)_ota_rep_err);
if (w < 0 || w >= rem) return;
mqtt_publish((char *)g_iot_topic.topic_pub, p, 1);
_ota_rep_times++;
_ota_rep_sent_ms = mstick();
PRINT("IOT: ota_report %s msg_id=%lu sent=%lu/%lu times=%u\n",
_ota_rep_ok ? "done" : "failed", _ota_rep_id,
(unsigned long)ota_flash_sent(), (unsigned long)ota_flash_total(),
_ota_rep_times);
}
/* OTA 刷写结果上报入口 (ota_srv.c ota_flash_done/fail 调用):
ok=1 stage=done, ok=0 stage=failed */
void iot_ota_report_result(uint8_t ok, uint32_t err)
{
_ota_rep_pend = 1;
_ota_rep_ok = ok ? 1 : 0;
_ota_rep_err = err;
_ota_rep_id = ++_evt_msg_id; /* 与 event_report 同源递增 (唯一性) */
_ota_rep_ts = dev_time_now();
_ota_rep_times = 0;
if (ok) _iot_ver_cache_dirty = 1; /* V1.10: 刷写成功 → Loop 跑新固件, 后台刷新版本缓存 */
iot_ota_report_send();
}
/* 主循环轮询: 5s×3 定时重发 (无 ACK, 纯推送 + 平台去重) */
static void iot_ota_report_poll(void)
{
if (!_ota_rep_pend) return;
if (g_iot_state != IOT_STATE_READY) return; /* 断网不消耗次数 */
if (_ota_rep_times > IOT_OTA_REP_MAX_RETRY) {
_ota_rep_pend = 0; /* 重发耗尽 (共 4 次) */
PRINT("IOT: ota_report retry exhausted\n");
return;
}
if (mstick() - _ota_rep_sent_ms < IOT_OTA_REP_INTERVAL_MS) return;
iot_ota_report_send();
}
/* 任一通道有车 (ota_flash 安全窗口检查用, 基于最新 0xC0 缓存) */
uint8_t iot_any_car(void)
{
uint8_t i;
if (!_cached_sr_valid) return 0;
for (i = 0; i < _cached_sr.coil_count && i < LUP_COIL_COUNT; i++) {
if (_cached_sr.coils[i].car_state) return 1;
}
return 0;
}
/* 事件发送状态机: 主循环每轮调用 (置于 READY 检查之前, 以感知重连沿) */
static void iot_evt_process(void) {
static uint8_t _was_ready = 0;
@@ -320,19 +448,32 @@ static int iot_mqtt_send(const uint8_t *buf, uint16_t len) {
uint32_t slen = len - total_sent;
uint8_t ret = WCHNET_SocketSend(g_iot_socket, (uint8_t *)(buf + total_sent), &slen);
total_sent += (uint16_t)slen;
if (slen == 0 || ret != WCHNET_ERR_SUCCESS || ++retries > 10) {
_iot_send_fail_cnt++;
PRINT("IOT: SocketSend FAIL#%d ret=0x%02X sent=%u/%u\n",
_iot_send_fail_cnt, ret, total_sent, len);
if (_iot_send_fail_cnt >= 3) {
PRINT("IOT: SocketSend 3 consecutive failures, forcing reconnect\n");
WCHNET_SocketClose(g_iot_socket, TCP_CLOSE_NORMAL);
g_iot_socket = 0xFF;
g_iot_state = IOT_STATE_DISCONNECTED;
_iot_send_fail_cnt = 0;
_iot_reconnect_deadline = mstick() + 3000; // 3s 后重连, 让 WCHNET 清旧 socket
if (slen == 0 || ret != WCHNET_ERR_SUCCESS) {
/* 0x11 = WCHNET_ERR_MEM: 瞬时发送缓冲不足 (WCHNET_NUM_TCP_SEG=2 极小,
)200ms 退 WCHNET SEG
: WCHNET socket
(SocketCreat ISCONN , 2026-08-20 ) */
if (ret == WCHNET_ERR_MEM && retries < 10) {
uint32_t w = mstick();
while ((mstick() - w) < 200) { } /* 退避 (IWDG 4s 内安全) */
}
return -1;
retries++;
if (retries > 10) {
_iot_send_fail_cnt++;
PRINT("IOT: SocketSend FAIL#%d ret=0x%02X sent=%u/%u\n",
_iot_send_fail_cnt, ret, total_sent, len);
if (_iot_send_fail_cnt >= 3) {
PRINT("IOT: SocketSend 3 consecutive failures, forcing reconnect\n");
WCHNET_SocketClose(g_iot_socket, TCP_CLOSE_NORMAL);
g_iot_socket = 0xFF;
SocketId_TCP = 0xFF; /* 防重连 SocketCreat 沿用旧 id (2026-08-20) */
g_iot_state = IOT_STATE_DISCONNECTED;
_iot_send_fail_cnt = 0;
_iot_reconnect_deadline = mstick() + 3000; // 3s 后重连, 让 WCHNET 清旧 socket
}
return -1;
}
continue; /* 重试 (MEM 已退避 200ms, 其他错误立即重试) */
}
}
_iot_send_fail_cnt = 0; // 成功一次就清零
@@ -418,6 +559,12 @@ static int iot_mqtt_publish(const char *topic, const char *payload,
/* 处理一条收到的 MQTT PUBLISH 消息 */
static void iot_handle_publish(const char *topic, uint8_t *payload, int payload_len) {
static char json[IOT_MQTT_RECV_BUF_LEN]; // static: 避免 1KB 栈开销
/* OTA 命令 IO 缓冲 (V1.08): 各分支响应组包 + ota_data hex 提取共用
(RAM 90% : 6 static ~2.5KB , union ~2KB) */
static union {
char resp[512]; /* ota_* 命令响应 JSON */
char hexbuf[512 + 1]; /* ota_data hex 字符串 (512 hex + NUL) */
} _ota_io;
int copy_len = payload_len < (int)sizeof(json) - 1 ? payload_len : (int)sizeof(json) - 1;
memcpy(json, payload, copy_len);
json[copy_len] = '\0';
@@ -454,17 +601,24 @@ static void iot_handle_publish(const char *topic, uint8_t *payload, int payload_
/* 目前仅实现基本响应框架, 后续逐步对接 Loop MCU 命令 */
if (strcmp(cmd_str, "dev_info_query") == 0) {
/* V1.10: data 补 loop_ver/loop_hw_ver (0x4A 缓存值, 查询未完成/失败为空串) */
char loop_ver_str[16], loop_hw_ver_str[16];
char data_json[512];
iot_loop_ver_str(loop_ver_str, sizeof(loop_ver_str));
iot_loop_hw_ver_str(loop_hw_ver_str, sizeof(loop_hw_ver_str));
snprintf(data_json, sizeof(data_json),
"{\"msg_id\":%lu,\"cmd\":\"dev_info_query\","
"\"ts\":%lu,\"code\":0,\"msg\":\"success\","
"\"data\":{"
"\"dev_serial\":\"%s\",\"hard_ver\":\"%s\",\"soft_ver\":\"%s\","
"\"loop_ver\":\"%s\",\"loop_hw_ver\":\"%s\","
"\"model\":\"%s\",\"product_code\":\"960001\","
"\"sub_code\":{\"net\":%s,\"iot\":%s},"
"\"bus\":{\"bus1\":0,\"bus2\":0,\"bus3\":0,\"bus4\":0}}}",
msg_id, dev_time_now(),
g_dev_number_str, HARDWARE_VER, FIRMWARE_VER, PRODUCT_MODEL,
g_dev_number_str, HARDWARE_VER, FIRMWARE_VER,
loop_ver_str, loop_hw_ver_str,
PRODUCT_MODEL,
g_sub_code_enable.net_enable ? "true" : "false",
g_sub_code_enable.iot_enable ? "true" : "false");
iot_mqtt_publish(resp_topic, data_json, strlen(data_json), 1);
@@ -677,6 +831,186 @@ static void iot_handle_publish(const char *topic, uint8_t *payload, int payload_
msg_id, dev_time_now());
iot_mqtt_publish(resp_topic, resp, strlen(resp), 1);
} else if (strcmp(cmd_str, "ota_begin") == 0) {
/* V1.08: 开启 OTA 会话 / 断点续传定位 (Loop MCU 远程 OTA, 先存后刷) */
uint32_t size = 0, crc32 = 0;
char version[16] = {0};
uint8_t force = 0;
int rc;
memset(tmp, 0, sizeof(tmp));
simple_parse_json(json, "\"size\"", tmp);
if (strlen(tmp) > 0) size = (uint32_t)strtoul(tmp, NULL, 10);
memset(tmp, 0, sizeof(tmp));
simple_parse_json(json, "\"crc32\"", tmp);
if (strlen(tmp) > 0) crc32 = (uint32_t)strtoul(tmp, NULL, 10);
memset(tmp, 0, sizeof(tmp));
simple_parse_json(json, "\"version\"", tmp);
strncpy(version, tmp, sizeof(version) - 1);
memset(tmp, 0, sizeof(tmp));
simple_parse_json(json, "\"force\"", tmp);
if (strlen(tmp) > 0) force = (strcmp(tmp, "true") == 0);
rc = ota_cmd_begin(size, crc32, version, force);
if (rc == 0) {
const OtaMeta *m = ota_meta();
snprintf(_ota_io.resp, sizeof(_ota_io.resp),
"{\"msg_id\":%lu,\"cmd\":\"ota_begin\",\"ts\":%lu,\"code\":0,\"msg\":\"success\","
"\"data\":{\"target\":\"loop\",\"slot\":\"%s\",\"offset\":%lu,\"received\":%lu,"
"\"size\":%lu,\"crc32\":%lu,\"state\":\"%s\"}}",
msg_id, dev_time_now(),
(m->slot == 0) ? "a" : "b",
(unsigned long)m->received, (unsigned long)m->received,
(unsigned long)m->size, (unsigned long)m->crc32,
ota_state_str((uint8_t)m->state));
} else {
snprintf(_ota_io.resp, sizeof(_ota_io.resp),
"{\"msg_id\":%lu,\"cmd\":\"ota_begin\",\"ts\":%lu,\"code\":1,\"msg\":\"param error\","
"\"data\":{\"target\":\"loop\",\"err_code\":%d}}",
msg_id, dev_time_now(), (rc == 3) ? 2 : 4);
}
iot_mqtt_publish(resp_topic, _ota_io.resp, strlen(_ota_io.resp), 1);
PRINT("IOT: ota_begin size=%lu rc=%d\n", (unsigned long)size, rc);
} else if (strcmp(cmd_str, "ota_data") == 0) {
/* V1.08: 分片下发 (256B/片, 单片 CRC32; 重复片幂等, 乱序拒收指示续传) */
uint32_t offset = 0, crc32 = 0;
int rc;
memset(tmp, 0, sizeof(tmp));
simple_parse_json(json, "\"offset\"", tmp);
if (strlen(tmp) > 0) offset = (uint32_t)strtoul(tmp, NULL, 10);
memset(tmp, 0, sizeof(tmp));
simple_parse_json(json, "\"crc32\"", tmp);
if (strlen(tmp) > 0) crc32 = (uint32_t)strtoul(tmp, NULL, 10);
/* hex 值提取: ota_json_extract_hex 兼容 "data":"..." 与 "data": "..." (json.dumps 带空格) */
ota_json_extract_hex(json, _ota_io.hexbuf, (int)sizeof(_ota_io.hexbuf));
rc = ota_cmd_data(offset, crc32, _ota_io.hexbuf);
if (rc == 0) {
const OtaMeta *m = ota_meta();
snprintf(_ota_io.resp, sizeof(_ota_io.resp),
"{\"msg_id\":%lu,\"cmd\":\"ota_data\",\"ts\":%lu,\"code\":0,\"msg\":\"success\","
"\"data\":{\"offset\":%lu,\"received\":%lu}}",
msg_id, dev_time_now(), (unsigned long)offset, (unsigned long)m->received);
} else if (rc == 1) {
const OtaMeta *m = ota_meta();
snprintf(_ota_io.resp, sizeof(_ota_io.resp),
"{\"msg_id\":%lu,\"cmd\":\"ota_data\",\"ts\":%lu,\"code\":1,\"msg\":\"crc/gap error\","
"\"data\":{\"err_code\":1,\"offset\":%lu,\"received\":%lu}}",
msg_id, dev_time_now(), (unsigned long)m->received, (unsigned long)m->received);
} else {
snprintf(_ota_io.resp, sizeof(_ota_io.resp),
"{\"msg_id\":%lu,\"cmd\":\"ota_data\",\"ts\":%lu,\"code\":3,\"msg\":\"busy\","
"\"data\":{\"err_code\":2}}",
msg_id, dev_time_now());
}
iot_mqtt_publish(resp_topic, _ota_io.resp, strlen(_ota_io.resp), 1);
PRINT("IOT: ota_data offset=%lu rc=%d\n", (unsigned long)offset, rc);
} else if (strcmp(cmd_str, "ota_end") == 0) {
/* V1.08: 结束下载, 全镜像 CRC32 复核 */
uint32_t crc32 = 0;
int rc;
memset(tmp, 0, sizeof(tmp));
simple_parse_json(json, "\"crc32\"", tmp);
if (strlen(tmp) > 0) crc32 = (uint32_t)strtoul(tmp, NULL, 10);
rc = ota_cmd_end(crc32);
if (rc == 0) {
snprintf(_ota_io.resp, sizeof(_ota_io.resp),
"{\"msg_id\":%lu,\"cmd\":\"ota_end\",\"ts\":%lu,\"code\":0,\"msg\":\"success\","
"\"data\":{\"crc_ok\":true}}",
msg_id, dev_time_now());
} else if (rc == 5) {
snprintf(_ota_io.resp, sizeof(_ota_io.resp),
"{\"msg_id\":%lu,\"cmd\":\"ota_end\",\"ts\":%lu,\"code\":5,\"msg\":\"crc mismatch\","
"\"data\":{\"err_code\":5,\"crc_ok\":false}}",
msg_id, dev_time_now());
} else if (rc == 3) {
snprintf(_ota_io.resp, sizeof(_ota_io.resp),
"{\"msg_id\":%lu,\"cmd\":\"ota_end\",\"ts\":%lu,\"code\":3,\"msg\":\"busy\","
"\"data\":{\"err_code\":2}}",
msg_id, dev_time_now());
} else {
const OtaMeta *m = ota_meta();
snprintf(_ota_io.resp, sizeof(_ota_io.resp),
"{\"msg_id\":%lu,\"cmd\":\"ota_end\",\"ts\":%lu,\"code\":1,\"msg\":\"incomplete\","
"\"data\":{\"offset\":%lu,\"received\":%lu}}",
msg_id, dev_time_now(), (unsigned long)m->received, (unsigned long)m->received);
}
iot_mqtt_publish(resp_topic, _ota_io.resp, strlen(_ota_io.resp), 1);
PRINT("IOT: ota_end rc=%d\n", rc);
} else if (strcmp(cmd_str, "ota_abort") == 0) {
/* V1.08: 中止会话, 释放暂存 */
int rc = ota_cmd_abort();
snprintf(_ota_io.resp, sizeof(_ota_io.resp),
"{\"msg_id\":%lu,\"cmd\":\"ota_abort\",\"ts\":%lu,\"code\":%d,\"msg\":\"%s\"}",
msg_id, dev_time_now(), (rc == 0) ? 0 : 1, (rc == 0) ? "success" : "internal error");
iot_mqtt_publish(resp_topic, _ota_io.resp, strlen(_ota_io.resp), 1);
PRINT("IOT: ota_abort rc=%d\n", rc);
} else if (strcmp(cmd_str, "ota_flash") == 0) {
/* V1.08: 触发本地 ISP 刷写 (同步安全检查 → 异步执行) */
char slot_buf[8] = {0};
uint8_t slot = 0, force = 0;
int rc;
memset(tmp, 0, sizeof(tmp));
simple_parse_json(json, "\"slot\"", tmp);
strncpy(slot_buf, tmp, sizeof(slot_buf) - 1);
if (strcmp(slot_buf, "b") == 0) slot = 1;
memset(tmp, 0, sizeof(tmp));
simple_parse_json(json, "\"force\"", tmp);
if (strlen(tmp) > 0) force = (strcmp(tmp, "true") == 0);
rc = ota_cmd_flash(slot, force);
if (rc == 0) {
snprintf(_ota_io.resp, sizeof(_ota_io.resp),
"{\"msg_id\":%lu,\"cmd\":\"ota_flash\",\"ts\":%lu,\"code\":0,\"msg\":\"success, flashing async\"}",
msg_id, dev_time_now());
} else if (rc == 3) {
snprintf(_ota_io.resp, sizeof(_ota_io.resp),
"{\"msg_id\":%lu,\"cmd\":\"ota_flash\",\"ts\":%lu,\"code\":3,\"msg\":\"busy\","
"\"data\":{\"err_code\":3}}",
msg_id, dev_time_now());
} else {
snprintf(_ota_io.resp, sizeof(_ota_io.resp),
"{\"msg_id\":%lu,\"cmd\":\"ota_flash\",\"ts\":%lu,\"code\":1,\"msg\":\"param error\"}",
msg_id, dev_time_now());
}
iot_mqtt_publish(resp_topic, _ota_io.resp, strlen(_ota_io.resp), 1);
PRINT("IOT: ota_flash slot=%d force=%d rc=%d\n", slot, force, rc);
} else if (strcmp(cmd_str, "ota_status") == 0) {
/* V1.08: 查询 OTA 状态 (含刷写进度) */
const OtaMeta *m = ota_meta();
snprintf(_ota_io.resp, sizeof(_ota_io.resp),
"{\"msg_id\":%lu,\"cmd\":\"ota_status\",\"ts\":%lu,\"code\":0,\"msg\":\"success\","
"\"data\":{\"target\":\"loop\",\"state\":\"%s\",\"slot\":\"%s\",\"size\":%lu,"
"\"received\":%lu,\"crc32\":%lu,\"version\":\"%s\","
"\"progress\":{\"sent\":%lu,\"total\":%lu},\"last_result\":%lu,\"last_error\":%lu}}",
msg_id, dev_time_now(),
ota_state_str((uint8_t)m->state), (m->slot == 0) ? "a" : "b",
(unsigned long)m->size, (unsigned long)m->received, (unsigned long)m->crc32,
m->version,
(unsigned long)ota_flash_sent(), (unsigned long)ota_flash_total(),
(unsigned long)m->last_result, (unsigned long)m->last_result);
iot_mqtt_publish(resp_topic, _ota_io.resp, strlen(_ota_io.resp), 1);
PRINT("IOT: ota_status state=%s\n", ota_state_str((uint8_t)m->state));
} else if (strcmp(cmd_str, "loop_version_query") == 0) {
/* V1.10 §4.25: 实时查询地感 Loop 版本 — 0x4A 异步, 响应在 iot_verq_poll() 回包
( TCP json_check_pending : msg_id, g_lup_cmd ) */
if (_iot_verq_pending) {
/* 已有查询在途, 拒绝重复查询 */
snprintf(_ota_io.resp, sizeof(_ota_io.resp),
"{\"msg_id\":%lu,\"cmd\":\"loop_version_query\",\"ts\":%lu,"
"\"code\":3,\"msg\":\"busy\"}",
msg_id, dev_time_now());
iot_mqtt_publish(resp_topic, _ota_io.resp, strlen(_ota_io.resp), 1);
} else {
lup_send_get_version();
_iot_verq_pending = 1;
_iot_verq_msg_id = msg_id;
_iot_verq_deadline = mstick() + 300; /* 与 TCP json_check_pending 一致 */
PRINT("IOT: loop_version_query sent (async)\n");
}
} else if (strcmp(cmd_str, "ssc_net_query") == 0) {
/* SSC 网络配置查询 (只读全局, 协议 V1.02) */
char resp[400];
@@ -740,15 +1074,21 @@ static void iot_handle_publish(const char *topic, uint8_t *payload, int payload_
/* 发送 initialize 上线消息 (IoT 路径) */
static void iot_send_initialize(void) {
/* V1.10: data 补 loop_ver/loop_hw_ver (0x4A 缓存值, 尽力携带可为空) */
char loop_ver_str[16], loop_hw_ver_str[16];
char payload[512];
char topic[IOT_MQTT_TOPIC_MAX_LEN];
iot_loop_ver_str(loop_ver_str, sizeof(loop_ver_str));
iot_loop_hw_ver_str(loop_hw_ver_str, sizeof(loop_hw_ver_str));
snprintf(payload, sizeof(payload),
"{\"msg_id\":%lu,\"cmd\":\"initialize\",\"ts\":%lu,"
"\"data\":{\"dev_serial\":\"%s\",\"model\":\"%s\","
"\"hard_ver\":\"%s\",\"soft_ver\":\"%s\","
"\"loop_ver\":\"%s\",\"loop_hw_ver\":\"%s\","
"\"extra_info\":{\"code\":\"0\",\"csq\":\"0\",\"location\":\"\"}}}",
(unsigned long)(g_iot_msg_id + 1), dev_time_now(),
g_iot_dev_serial, PRODUCT_MODEL, HARDWARE_VER, FIRMWARE_VER);
g_iot_dev_serial, PRODUCT_MODEL, HARDWARE_VER, FIRMWARE_VER,
loop_ver_str, loop_hw_ver_str);
snprintf(topic, sizeof(topic), "dld960/%s/dev", g_iot_dev_serial);
iot_mqtt_publish(topic, payload, strlen(payload), 0);
}
@@ -899,6 +1239,9 @@ void iot_mqtt_publish_sensor(void) {
/*--- 事件上报状态机: 发送/重发/重连补报 ---*/
iot_evt_process();
/*--- OTA 结果上报重发 (V1.09): done/failed 5s×3, 同 msg_id/ts ---*/
iot_ota_report_poll();
if (g_iot_state != IOT_STATE_READY) return;
if (!g_report_cfg.enable) return;
@@ -1105,9 +1448,97 @@ void iot_watchdog_kick(void) {
/*===========================================================================
*
*===========================================================================*/
/* V1.10: Loop 版本查询回包 (loop_version_query 异步) + 版本缓存后台刷新
TCP g_lup_cmd (, ) */
static void iot_verq_publish(const char *payload, uint16_t len)
{
char topic[IOT_MQTT_TOPIC_MAX_LEN];
snprintf(topic, sizeof(topic), "dld960/%s/dev", g_iot_dev_serial);
iot_mqtt_publish(topic, payload, len, 1);
}
static void iot_verq_poll(void)
{
if (_iot_verq_pending) {
/* 命令查询在途: 等 0x4A 响应或超时 */
if (g_lup_cmd.state == LUP_STATE_RESPONSE_READY) {
LUP_VersionInfo info;
char resp[320];
char ver_str[16], hw_str[16], vs_str[64];
int ok = 0;
memset(&info, 0, sizeof(info));
if (lup_parse_version(g_lup_cmd.resp_buf, g_lup_cmd.resp_len, &info) == 0) {
lup_cache_from_info(&info); /* 同步刷新缓存 */
snprintf(ver_str, sizeof(ver_str), "%d.%d.%d",
info.soft_main, info.soft_sub, info.soft_ssub);
snprintf(hw_str, sizeof(hw_str), "%d.%d.%d",
info.hard_main, info.hard_sub, info.hard_ssub);
snprintf(vs_str, sizeof(vs_str), "V%s (HW:%s)", ver_str, hw_str);
snprintf(resp, sizeof(resp),
"{\"msg_id\":%lu,\"cmd\":\"loop_version_query\",\"ts\":%lu,\"code\":0,"
"\"msg\":\"success\","
"\"data\":{\"loop_ver\":\"%s\",\"loop_hw_ver\":\"%s\",\"version_str\":\"%s\"}}",
_iot_verq_msg_id, dev_time_now(), ver_str, hw_str, vs_str);
ok = 1;
} else {
char lv[16], lhv[16];
iot_loop_ver_str(lv, sizeof(lv));
iot_loop_hw_ver_str(lhv, sizeof(lhv));
snprintf(resp, sizeof(resp),
"{\"msg_id\":%lu,\"cmd\":\"loop_version_query\",\"ts\":%lu,\"code\":5,"
"\"msg\":\"parse error\","
"\"data\":{\"loop_ver\":\"%s\",\"loop_hw_ver\":\"%s\",\"version_str\":\"\"}}",
_iot_verq_msg_id, dev_time_now(), lv, lhv);
}
lup_cmd_done();
_iot_verq_pending = 0;
iot_verq_publish(resp, strlen(resp));
PRINT("IOT: loop_version_query resp %s\n", ok ? "ok" : "parse error");
} else if (g_lup_cmd.state == LUP_STATE_TIMEOUT || mstick() > _iot_verq_deadline) {
char resp[320];
char lv[16], lhv[16];
iot_loop_ver_str(lv, sizeof(lv));
iot_loop_hw_ver_str(lhv, sizeof(lhv));
snprintf(resp, sizeof(resp),
"{\"msg_id\":%lu,\"cmd\":\"loop_version_query\",\"ts\":%lu,\"code\":5,"
"\"msg\":\"no response\","
"\"data\":{\"loop_ver\":\"%s\",\"loop_hw_ver\":\"%s\",\"version_str\":\"\"}}",
_iot_verq_msg_id, dev_time_now(), lv, lhv);
lup_cmd_done();
_iot_verq_pending = 0;
iot_verq_publish(resp, strlen(resp));
PRINT("IOT: loop_version_query timeout\n");
}
/* 其它 state (WAIT_RESPONSE) 继续等 */
} else {
/* 后台缓存刷新: 上电 / OTA done 后置 dirty, 命令通道空闲时发 0x4A */
if (_iot_ver_cache_dirty && g_lup_cmd.state == LUP_STATE_IDLE) {
lup_send_get_version();
_iot_ver_cache_dirty = 0;
PRINT("IOT: loop version cache refresh query\n");
} else if (!_iot_ver_cache_dirty && g_lup_cmd.state == LUP_STATE_RESPONSE_READY) {
/* 后台查询响应 (无命令 pending) → 更新缓存 + 释放通道 */
LUP_VersionInfo info;
memset(&info, 0, sizeof(info));
if (lup_parse_version(g_lup_cmd.resp_buf, g_lup_cmd.resp_len, &info) == 0) {
lup_cache_from_info(&info);
PRINT("IOT: loop version cache refreshed %d.%d.%d (HW:%d.%d.%d)\n",
info.soft_main, info.soft_sub, info.soft_ssub,
info.hard_main, info.hard_sub, info.hard_ssub);
}
lup_cmd_done();
} else if (g_lup_cmd.state == LUP_STATE_TIMEOUT) {
lup_cmd_done(); /* 清残留超时态 (后台查询失败或被其它命令覆盖) */
}
}
}
void iot_mqtt_poll(void) {
iot_watchdog_kick(); // 喂硬件 IWDG
/* V1.10: Loop 版本查询回包 + 缓存后台刷新 (放最前: 断连提前 return 前也要推进状态机) */
iot_verq_poll();
/* 事件日志: MQTT 状态沿检测 (主循环上下文, 不在 socket 中断里写 SPI)
BOOT + boot_seq ; */
if (g_iot_state != _prev_iot_state) {
@@ -1194,6 +1625,9 @@ void iot_mqtt_init(void) {
_iot_reconnect_deadline = mstick() + 2000; // 启动后 2s 开始首次连接
g_iot_state = IOT_STATE_DISCONNECTED;
/* V1.10: 上电后台查询一次 Loop 版本 (0x4A), 缓存供 dev_info_query/initialize */
_iot_ver_cache_dirty = 1;
/* 初始化硬件看门狗 IWDG (LSI≈40kHz, 4s 超时, 主循环喂狗) */
iot_watchdog_init();
}
@@ -509,6 +509,31 @@ void lup_send_get_version(void)
lup_cmd_begin(LUP_CMD_GET_VERSION, 200);
}
/*===========================================================================
* (V1.10) 0x4A , MQTT dev_info_query/initialize
*===========================================================================*/
LoopVerCache g_lup_ver_cache; /* BSS 清零: valid=0 */
void lup_cache_from_info(const LUP_VersionInfo *info)
{
if (!info) return;
g_lup_ver_cache.valid = 1;
g_lup_ver_cache.hard_main = info->hard_main;
g_lup_ver_cache.hard_sub = info->hard_sub;
g_lup_ver_cache.hard_ssub = info->hard_ssub;
g_lup_ver_cache.soft_main = info->soft_main;
g_lup_ver_cache.soft_sub = info->soft_sub;
g_lup_ver_cache.soft_ssub = info->soft_ssub;
}
/* 后台刷新: 仅命令通道空闲时发起 (g_lup_cmd 全局唯一, 与 TCP/MQTT 命令共用
; , ) */
void lup_refresh_version_cache(void)
{
if (g_lup_cmd.state != LUP_STATE_IDLE) return;
lup_send_get_version();
}
void lup_send_reset(void)
{
uint8_t buf[LUP_MAX_PKG_LEN];
@@ -346,6 +346,13 @@ void WCHNET_CreateTcpMqttSocket(void)
{
PRINT("WCHNET_MQTT_SocketCreate %d,desIP:%d.%d.%d.%d, des_port:%d, srcport:%d\r\n", SocketId_TCP, RemoteIP[0],RemoteIP[1],RemoteIP[2],RemoteIP[3], iot_net_info.mqtt_port, TmpSocketInf.SourPort);
}
if (i != WCHNET_ERR_SUCCESS) {
/* 2026-08-20: SocketCreat 失败 (0x1D ISCONN 等, 旧 socket 未清干净)
, 退 Connect */
PRINT("MQTT: SocketCreat failed 0x%02X, abort connect\r\n", i);
SocketId_TCP = 0xFF;
return;
}
mStopIfError(i);
i = WCHNET_SocketConnect(SocketId_TCP); //make a TCP connection
if(i == WCHNET_ERR_SUCCESS)
@@ -342,6 +342,28 @@ void offlog_coil(uint8_t sub, uint8_t ch, uint32_t value)
offlog_evt(OFFLOG_EVT_COIL, p, 6);
}
void offlog_ota_start(uint8_t slot, uint32_t size)
{
uint8_t p[6];
p[0] = slot;
p[1] = 0; /* target: 0=loop */
p[2] = (uint8_t)(size >> 24);
p[3] = (uint8_t)(size >> 16);
p[4] = (uint8_t)(size >> 8);
p[5] = (uint8_t)size;
offlog_evt(OFFLOG_EVT_OTA_START, p, 6);
}
void offlog_ota_result(uint32_t code)
{
uint8_t p[4];
p[0] = (uint8_t)(code >> 24);
p[1] = (uint8_t)(code >> 16);
p[2] = (uint8_t)(code >> 8);
p[3] = (uint8_t)code;
offlog_evt(OFFLOG_EVT_OTA_RESULT, p, 4);
}
void offlog_time_anchor(uint32_t unix_ts)
{
offlog_evt_ts(OFFLOG_EVT_TIME_ANCHOR, NULL, 0, unix_ts); /* 严格用平台下发值 */
@@ -398,6 +420,8 @@ const char *offlog_type_str(uint8_t type)
case OFFLOG_EVT_EVT_GIVEUP: return "evt_giveup";
case OFFLOG_EVT_COIL: return "coil";
case OFFLOG_EVT_TIME_ANCHOR: return "time_anchor";
case OFFLOG_EVT_OTA_START: return "ota_start";
case OFFLOG_EVT_OTA_RESULT: return "ota_result";
case OFFLOG_EVT_LOG_CLEAR: return "log_clear";
default: return "unknown";
}
@@ -0,0 +1,633 @@
/**
******************************************************************************
* @file ota_srv.c
* @author wangfq
* @version V1.0
* @date 2026-08-20
* @brief DLD960 Loop MCU OTA (MQTT V1.08, ROADMAP P1.4 )
*
* :
* 1 : MQTT (256B/, + CRC32) W25Qxx
* 2 : ota_end CRC32 state=ready
* 3 : ota_flash 0x9F ISP (A5/A6/A7 ),
* tick ota_poll(), MQTT
*
* : downloading/flashing event_report (,
* ) + offlog/ ("升级开始",
* "升级结果")
*
* : docs/DLD960_IoT_MQTT协议.md §4.19~4.24 / §5.5
* 稿: docs/DLD960_MQTT_OTA协议.md V1.01
******************************************************************************
*/
#include "CONFIG.h"
#include "cmcng.h"
#include "ota_srv.h"
#include "offlog.h"
#include "storage.h"
#include "loop_uart_proto.h"
#include <string.h>
/*===========================================================================
*
*===========================================================================*/
static OtaMeta _meta; /* 当前会话元数据 (RAM 副本) */
static uint32_t _slot_base; /* 当前 Slot 数据区基址 */
static uint32_t _meta_watermark; /* 上次落盘元数据时的 received (节流) */
static uint8_t _init_done; /* ota_init 完成 */
/* 刷写状态机 (非阻塞 tick 驱动) */
static uint8_t _fs; /* OTA_FLASH_* */
static uint8_t _fs_retry; /* 当前步重试计数 */
static uint32_t _fs_start_ms; /* 当前步起始时刻 (超时判断) */
static uint16_t _fs_sub; /* 当前块序号 (首=总块数, 末=1) */
static uint32_t _fs_sent; /* 已送 Loop 字节数 */
uint8_t g_ota_flash_active = 0; /* 本地刷写进行中 → uart_srv 把 ACK 交给 ota_srv */
/* 块缓冲 (static: 栈紧张教训; union: 下载片 256B 与 A7 帧 254B 互斥复用, 省 ~260B RAM) */
static union {
uint8_t chunk[OTA_CHUNK_SIZE]; /* 下载片缓冲 / 校验读缓冲 */
uint8_t frame[5 + OTA_FLASH_BLOCK + 1]; /* A7 帧: 9F+SubL+SubH+LEN+CMD+DATA+CHECK */
} _ota_buf;
/*===========================================================================
* CRC-32/ISO-HDLC ( §4.19.1; , RAM)
* poly=0x04C11DB7 (reflected 0xEDB88320), init=0xFFFFFFFF, xorout=0xFFFFFFFF
* : crc32("123456789") = 0xCBF43926
*===========================================================================*/
/* 增量更新: 调用方初始 crc=0xFFFFFFFF, 分块续算, 最后 ^0xFFFFFFFF
( 96KB , RAM) */
static uint32_t ota_crc32_update(uint32_t crc, const uint8_t *data, uint32_t len)
{
uint32_t i;
uint8_t b;
for (i = 0; i < len; i++) {
crc ^= data[i];
for (b = 0; b < 8; b++) {
crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u)));
}
}
return crc;
}
uint32_t ota_crc32(const uint8_t *data, uint32_t len)
{
return ota_crc32_update(0xFFFFFFFFu, data, len) ^ 0xFFFFFFFFu;
}
/*===========================================================================
* hex
*===========================================================================*/
static uint8_t ota_hex_val(char c)
{
if (c >= '0' && c <= '9') return (uint8_t)(c - '0');
if (c >= 'a' && c <= 'f') return (uint8_t)(c - 'a' + 10);
if (c >= 'A' && c <= 'F') return (uint8_t)(c - 'A' + 10);
return 0xFF;
}
int ota_hex_decode(const char *hex, uint8_t *out, uint32_t n)
{
uint32_t i;
for (i = 0; i < n; i++) {
uint8_t hi = ota_hex_val(hex[i * 2]);
uint8_t lo = ota_hex_val(hex[i * 2 + 1]);
if (hi == 0xFF || lo == 0xFF) return -1;
out[i] = (uint8_t)((hi << 4) | lo);
}
return 0;
}
/* 从 MQTT JSON 提取 ota_data 的 data 字段 hex 值。
2026-08-20 : json.dumps ("data": "hex"),
strstr("\"data\":\"") CRC
"data" /, */
int ota_json_extract_hex(const char *json, char *out, int out_len)
{
const char *p = json;
int n = 0;
if (!json || !out || out_len <= 0) return 0;
out[0] = '\0';
while (*p) {
if (strncmp(p, "\"data\"", 6) == 0) {
const char *q = p + 6;
while (*q == ' ' || *q == '\t') q++;
if (*q == ':') {
q++;
while (*q == ' ' || *q == '\t') q++;
if (*q == '"') {
q++; /* 跳过起始引号 */
while (*q && *q != '"' && n < out_len - 1) {
out[n++] = *q;
q++;
}
out[n] = '\0';
return n;
}
}
}
p++;
}
return 0;
}
/*===========================================================================
* (, 0x010000~0x0103FF)
* : (45ms) + NoCheck +
* : magic , ; IDLE
*===========================================================================*/
static void ota_meta_write(const OtaMeta *m)
{
uint8_t buf[64];
memset(buf, 0xFF, sizeof(buf));
memcpy(buf, m, sizeof(OtaMeta));
SPI_Flash_Erase_Sector((uint32_t)OTA_META_SECTOR);
SPI_Flash_Write_NoCheck(buf, OTA_META_BASE, 64);
SPI_Flash_Write_NoCheck(buf, OTA_META_BACKUP, 64);
PRINT("OTA: meta write state=%lu received=%lu\n",
(unsigned long)m->state, (unsigned long)m->received);
}
static int ota_meta_read(OtaMeta *m)
{
uint8_t buf[64];
memset(buf, 0xFF, sizeof(buf));
SPI_Flash_Read(buf, OTA_META_BASE, 64);
memcpy(m, buf, sizeof(OtaMeta));
if (m->magic == OTA_MAGIC) return 0;
memset(buf, 0xFF, sizeof(buf));
SPI_Flash_Read(buf, OTA_META_BACKUP, 64);
memcpy(m, buf, sizeof(OtaMeta));
if (m->magic == OTA_MAGIC) return 0;
return -1;
}
/* 节流落盘: 每累计 4KB 或关键节点写元数据 (擦 45ms 不能每片做) */
static void ota_meta_flush(void)
{
if (_meta.received - _meta_watermark >= 4096u) {
ota_meta_write(&_meta);
_meta_watermark = _meta.received;
}
}
/*===========================================================================
*
*===========================================================================*/
const char *ota_state_str(uint8_t state)
{
static const char *names[] = {
"idle", "downloading", "ready", "flashing", "flash_failed", "aborted"
};
return (state < 6) ? names[state] : "idle";
}
uint8_t ota_silent_active(void)
{
return (_meta.state == OTA_STATE_DOWNLOADING || _meta.state == OTA_STATE_FLASHING);
}
const OtaMeta *ota_meta(void)
{
return &_meta;
}
/*===========================================================================
*
*===========================================================================*/
void ota_init(void)
{
OtaMeta m;
memset(&m, 0, sizeof(m));
if (ota_meta_read(&m) == 0) {
/* 恢复上次会话状态 (掉电/复位后) */
_meta = m;
if (_meta.state == OTA_STATE_DOWNLOADING) {
/* 下载中断: 保持 downloading, 平台 ota_begin 时续传 */
PRINT("OTA: resume downloading received=%lu\n", (unsigned long)_meta.received);
} else if (_meta.state == OTA_STATE_FLASHING) {
/* 刷写中断 (掉电): 置失败态, 保留镜像可重试 */
_meta.state = OTA_STATE_FLASH_FAILED;
_meta.last_result = OTA_ERR_READY_TIMEOUT;
PRINT("OTA: flash interrupted on boot, mark failed\n");
ota_meta_write(&_meta);
}
/* ready/aborted/flash_failed/idle: 原样保留 */
} else {
memset(&_meta, 0, sizeof(_meta));
_meta.magic = OTA_MAGIC;
_meta.state = OTA_STATE_IDLE;
_meta.slot = 0;
PRINT("OTA: fresh meta (idle)\n");
}
_slot_base = (_meta.slot == 0) ? OTA_SLOT_A_BASE : OTA_SLOT_B_BASE;
_meta_watermark = _meta.received;
_fs = OTA_FLASH_IDLE;
_init_done = 1;
PRINT("OTA: init done state=%s slot=%d received=%lu\n",
ota_state_str(_meta.state), _meta.slot, (unsigned long)_meta.received);
}
/*===========================================================================
* ota_begin ( / )
* : 0= (data.offset = _meta.received size), 0=
*===========================================================================*/
int ota_cmd_begin(uint32_t size, uint32_t crc32, const char *version, uint8_t force)
{
(void)force; /* 版本冲突判断暂不校验 (bootloader 不校验版本), 预留 */
if (size == 0 || size > OTA_MAX_IMAGE) {
return 1; /* 参数错误 */
}
/* 已有镜像且 size+crc32 一致 → 续传或直接 ready */
if (_meta.magic == OTA_MAGIC && _meta.size == size && _meta.crc32 == crc32) {
_slot_base = (_meta.slot == 0) ? OTA_SLOT_A_BASE : OTA_SLOT_B_BASE;
if (_meta.state == OTA_STATE_READY) {
/* 镜像已就绪, 平台可直接 ota_flash */
return 0; /* data.offset = size */
}
if (_meta.state == OTA_STATE_IDLE) {
/* V1.09: 刷写完成后的 idle 且镜像一致 → 直接回 ready, 免下载可重刷 */
_meta.state = OTA_STATE_READY;
ota_meta_write(&_meta);
PRINT("OTA: begin idle+same image → ready (reflash)\n");
return 0; /* data.offset = size */
}
if (_meta.state == OTA_STATE_DOWNLOADING) {
_meta.state = OTA_STATE_DOWNLOADING;
PRINT("OTA: begin resume received=%lu\n", (unsigned long)_meta.received);
return 0; /* data.offset = received */
}
/* flash_failed / aborted: 重新走下载 */
}
/* 新会话: 始终用 Slot A (B 保留上一版镜像做回滚) */
_slot_base = OTA_SLOT_A_BASE;
memset(&_meta, 0, sizeof(_meta));
_meta.magic = OTA_MAGIC;
_meta.state = OTA_STATE_DOWNLOADING;
_meta.target = 0;
_meta.slot = 0;
_meta.size = size;
_meta.crc32 = crc32;
_meta.received = 0;
_meta.last_result = 0;
_meta.begin_ts = 0;
memset(_meta.version, 0, sizeof(_meta.version));
if (version) strncpy(_meta.version, version, sizeof(_meta.version) - 1);
_meta.flash_cnt = 0;
_meta_watermark = 0;
/* 先擦目标 Slot 数据区 (100KB = 25 扇区 ~1.1s, 主循环上下文可接受 < IWDG 4s) */
{
uint32_t sec;
for (sec = 0; sec < OTA_SLOT_SIZE / 4096UL; sec++) {
SPI_Flash_Erase_Sector((uint32_t)(_slot_base / 4096UL) + sec);
}
}
ota_meta_write(&_meta);
PRINT("OTA: begin new session size=%lu crc32=0x%08lX slot=%d\n",
(unsigned long)size, (unsigned long)crc32, _meta.slot);
return 0;
}
/*===========================================================================
* ota_data (, 256B/)
* : 0= (), 1=/CRC/ (err_code ), 3=
*===========================================================================*/
int ota_cmd_data(uint32_t offset, uint32_t crc32, const char *hex)
{
uint32_t clen;
if (_meta.state != OTA_STATE_DOWNLOADING) {
return 3; /* 会话状态不允许 (err_code=2 由调用方映射) */
}
if (offset % OTA_CHUNK_SIZE != 0 || offset >= _meta.size) {
return 1; /* 参数错误 */
}
if (offset < _meta.received) {
return 0; /* 重复片: 幂等 */
}
if (offset > _meta.received) {
return 1; /* 乱序缺片: 调用方用 data.offset=received 指示续传 */
}
clen = _meta.size - offset;
if (clen > OTA_CHUNK_SIZE) clen = OTA_CHUNK_SIZE;
if (ota_hex_decode(hex, _ota_buf.chunk, clen) != 0) {
return 1; /* hex 格式错误 */
}
if (ota_crc32(_ota_buf.chunk, clen) != crc32) {
return 1; /* 单片 CRC 失败 */
}
/* 落盘: SPI_Flash_Write 自带"目标区非 0xFF 则读-改-写擦扇区" (256B 页对齐) */
SPI_Flash_Write(_ota_buf.chunk, _slot_base + offset, (uint16_t)clen);
_meta.received = offset + clen;
ota_meta_flush();
PRINT("OTA: data@%lu crc_ok received=%lu\n",
(unsigned long)offset, (unsigned long)_meta.received);
return 0;
}
/*===========================================================================
* ota_end ( CRC32 )
*===========================================================================*/
int ota_cmd_end(uint32_t crc32)
{
if (_meta.state != OTA_STATE_DOWNLOADING) {
return 3;
}
if (_meta.received != _meta.size) {
return 1; /* 不完整: data.offset=received 续传 */
}
if (crc32 != _meta.crc32) {
return 1; /* 声明值不一致 */
}
/* 读回暂存区计算全镜像 CRC32 (分块增量, 96KB ≈ 40ms @120MHz, 主循环上下文) */
{
uint32_t total = 0xFFFFFFFFu;
uint32_t i = 0;
while (i < _meta.size) {
uint32_t n = _meta.size - i;
if (n > OTA_CHUNK_SIZE) n = OTA_CHUNK_SIZE;
SPI_Flash_Read(_ota_buf.chunk, _slot_base + i, (uint16_t)n);
total = ota_crc32_update(total, _ota_buf.chunk, n);
i += n;
}
total ^= 0xFFFFFFFFu;
if (total != _meta.crc32) {
_meta.state = OTA_STATE_DOWNLOADING; /* 保留已下载数据, 可重发错片 */
PRINT("OTA: end CRC mismatch 0x%08lX != 0x%08lX\n",
(unsigned long)total, (unsigned long)_meta.crc32);
return 5; /* 内部错误 + data.crc_ok=false */
}
}
_meta.state = OTA_STATE_READY;
_meta.last_result = 0;
ota_meta_write(&_meta);
PRINT("OTA: end OK crc_ok → ready (slot %d)\n", _meta.slot);
return 0;
}
/*===========================================================================
* ota_abort
*===========================================================================*/
int ota_cmd_abort(void)
{
if (_meta.state == OTA_STATE_IDLE) {
return 0; /* 无会话: 幂等 */
}
_meta.state = OTA_STATE_ABORTED;
ota_meta_write(&_meta);
PRINT("OTA: abort → aborted\n");
return 0;
}
/*===========================================================================
* ota_flash ( ISP , + )
* : 4 (err_code=3); force
*===========================================================================*/
int ota_cmd_flash(uint8_t slot, uint8_t force)
{
uint8_t any_car;
extern uint8_t iot_any_car(void);
if (_meta.state != OTA_STATE_READY) {
return 3; /* 非 ready: 先 ota_end */
}
if (slot != 0 && slot != 1) {
return 1;
}
if (slot != _meta.slot) {
/* 指定了非当前 ready 槽: 读该槽元数据? 简化: 仅允许当前槽 */
return 1;
}
/* 与 BLE OTA 透传互斥: g_flag_counter_ota.flag=1 且非本地刷写 → 拒绝 */
if (g_flag_counter_ota.flag != 0 && !g_ota_flash_active) {
return 3;
}
/* 安全窗口: 有车压线圈拒绝 (force 跳过) */
any_car = iot_any_car();
if (!force && any_car) {
PRINT("OTA: flash rejected, car on loop\n");
return 3;
}
/* 启动刷写 (异步): offlog 开始事件先写 (会话静默前) */
offlog_ota_start(_meta.slot, _meta.size);
_meta.state = OTA_STATE_FLASHING;
_meta.flash_cnt++;
_meta.last_result = 0;
ota_meta_write(&_meta);
_fs = OTA_FLASH_START;
_fs_retry = 0;
_fs_sent = 0;
_fs_sub = 0;
g_ota_flash_active = 1;
g_flag_counter_ota.flag = 1; /* UART2 切 0x9F 解析 */
g_flag_counter_ota.tick = 0;
PRINT("OTA: flash start slot=%d size=%lu (force=%d)\n",
_meta.slot, (unsigned long)_meta.size, force);
return 0;
}
/*===========================================================================
* ( tick , ota_poll )
*
* A5 pre_ok A6 addr_ok A7 ×N (=1)
* 1s ×3; state=flash_failed + event_report ota_error
*===========================================================================*/
/* 0x9F 帧发送: 9F SubL SubH LEN CMD DATA... CHECK(SUM 不含 magic) */
static void ota_send_9f(uint8_t sub_l, uint8_t sub_h, uint8_t cmd,
const uint8_t *data, uint16_t datalen)
{
uint16_t i;
uint8_t sum = 0;
_ota_buf.frame[0] = 0x9F;
_ota_buf.frame[1] = sub_l;
_ota_buf.frame[2] = sub_h;
_ota_buf.frame[3] = (uint8_t)(1 + datalen); /* LEN 含 CMD */
_ota_buf.frame[4] = cmd;
if (data && datalen) memcpy(&_ota_buf.frame[5], data, datalen);
for (i = 1; i < 5 + datalen; i++) sum += _ota_buf.frame[i]; /* SubL..DATA */
_ota_buf.frame[5 + datalen] = sum;
UART2_SendString(_ota_buf.frame, (uint16_t)(5 + datalen + 1));
}
/* 刷写失败收尾 (V1.09): 状态落盘 + ota_report failed + event_report 告警 + 恢复 0x7F 模式 */
static void ota_flash_fail(uint32_t err)
{
PRINT("OTA: flash FAILED err=0x%lX\n", (unsigned long)err);
_meta.state = OTA_STATE_FLASH_FAILED;
_meta.last_result = err;
ota_meta_write(&_meta);
offlog_ota_result(err);
/* 恢复 UART2 0x7F 模式 */
g_ota_flash_active = 0;
g_flag_counter_ota.flag = 0;
lup_frame_reset();
_fs = OTA_FLASH_IDLE;
/* ota_report stage=failed (平台判定主依据, V1.09) */
extern void iot_ota_report_result(uint8_t ok, uint32_t err);
iot_ota_report_result(0, err);
/* event_report ota_error 告警 (平台必答, 由 iot_mqtt_srv 发送) */
extern void iot_evt_report_ota_error(uint32_t err);
iot_evt_report_ota_error(err);
}
/* 刷写成功收尾 (V1.09): 状态回 idle 但镜像保留 (可重刷, last_result=0),
ota_report stage=done (), 0x7F */
static void ota_flash_done(void)
{
PRINT("OTA: flash DONE sent=%lu\n", (unsigned long)_fs_sent);
_meta.state = OTA_STATE_IDLE; /* V1.09: idle+size>0 = 已刷写完成 (区别于 ready=待刷) */
_meta.last_result = 0;
ota_meta_write(&_meta);
offlog_ota_result(0);
g_ota_flash_active = 0;
g_flag_counter_ota.flag = 0;
lup_frame_reset();
_fs = OTA_FLASH_IDLE;
/* ota_report stage=done (平台判定主依据, V1.09) */
extern void iot_ota_report_result(uint8_t ok, uint32_t err);
iot_ota_report_result(1, 0);
}
/* UART2 收到 bootloader 0x9F ACK 帧 (uart_srv OTA 分支调用, 主循环上下文)
* ACK : 9F SubL SubH 02 CMD Status CHECK
* pre_ok = 9F 01 00 02 A5 00 A8
* addr_ok = 9F 01 00 02 A6 00 A9 | addr_err = 9F 01 00 02 A6 01 AA
* data ack= 9F SubL SubH 02 A7 00 CK | data err = 9F SubL SubH 02 A7 01 CK */
void ota_flash_feed_ack(const uint8_t *pkg, uint16_t len)
{
uint8_t cmd, status;
if (len < 7 || pkg[0] != 0x9F) return;
if (pkg[3] != 0x02) return; /* ACK 帧 LEN=2 */
cmd = pkg[4];
status = pkg[5];
PRINT("OTA: ack cmd=0x%02X status=%d sub=%u\n", cmd, status, pkg[1]);
switch (_fs) {
case OTA_FLASH_WAIT_READY:
if (cmd == 0xA5 && status == 0x00) {
_fs = OTA_FLASH_ADDR;
_fs_retry = 0;
}
break;
case OTA_FLASH_WAIT_ADDR:
if (cmd == 0xA6 && status == 0x00) {
/* 地址 OK → 进入数据阶段: 首块序号 = 总块数 (末块=1) */
uint32_t blocks = (_meta.size + OTA_FLASH_BLOCK - 1) / OTA_FLASH_BLOCK;
_fs_sub = (uint16_t)(blocks > 0xFFFF ? 0xFFFF : blocks);
_fs_sent = 0;
_fs = OTA_FLASH_DATA;
_fs_retry = 0;
} else if (cmd == 0xA6 && status != 0x00) {
ota_flash_fail(OTA_ERR_ADDR_FAIL);
}
break;
case OTA_FLASH_WAIT_ACK:
if (cmd == 0xA7) {
if (status == 0x00) {
_fs_sent += OTA_FLASH_BLOCK;
if (_fs_sent >= _meta.size) _fs_sent = _meta.size;
_fs_retry = 0;
if (_fs_sub <= 1) {
/* 末块已 ACK → 完成 (bootloader 清 flag 复位跑新 APP) */
ota_flash_done();
} else {
_fs_sub--; /* 序号递减: 首=总块数, 末=1 */
_fs = OTA_FLASH_DATA;
}
} else {
/* 数据块错误: 重发同块 (序号不变) */
_fs_retry++;
if (_fs_retry > 3) {
ota_flash_fail(OTA_ERR_DATA_ACK_LIMIT);
} else {
_fs = OTA_FLASH_DATA;
}
}
}
break;
default:
break;
}
}
uint32_t ota_flash_sent(void) { return _fs_sent; }
uint32_t ota_flash_total(void) { return _meta.size; }
/* 主循环轮询: 推进刷写状态机 (每轮最多发 1~2 块, 绝不阻塞) */
void ota_poll(void)
{
uint32_t now;
if (!_init_done || _fs == OTA_FLASH_IDLE) return;
now = mstick();
if (_fs == OTA_FLASH_START) {
/* 发 A5 启动帧: 9F 01 00 01 A5 A7 → Loop APP 写 flag 复位 → bootloader 回 pre_ok */
static const uint8_t start_frame[6] = {0x9F, 0x01, 0x00, 0x01, 0xA5, 0xA7};
UART2_SendString((uint8_t *)start_frame, 6);
_fs = OTA_FLASH_WAIT_READY;
_fs_start_ms = now;
/* 重试计数保持 (首发由 ota_cmd_flash 置 0; 重发不清零, 否则超时重试永不失败) */
PRINT("OTA: sent A5 start\n");
return;
}
if (_fs == OTA_FLASH_WAIT_READY) {
if (now - _fs_start_ms > 1000u) {
_fs_retry++;
if (_fs_retry > 3) { ota_flash_fail(OTA_ERR_READY_TIMEOUT); return; }
_fs = OTA_FLASH_START; /* 重发启动帧 */
}
return;
}
if (_fs == OTA_FLASH_ADDR) {
/* 发 A6 地址帧: 9F 01 00 05 A6 08 00 34 00 SUM (0x08003400 大端) */
uint8_t addr[4] = {0x08, 0x00, 0x34, 0x00}; /* APP_START_ADDR */
ota_send_9f(0x01, 0x00, 0xA6, addr, 4);
_fs = OTA_FLASH_WAIT_ADDR;
_fs_start_ms = now;
/* 重试计数保持 (ACK 成功路径在 ota_flash_feed_ack 清 0) */
return;
}
if (_fs == OTA_FLASH_WAIT_ADDR) {
if (now - _fs_start_ms > 1000u) {
_fs_retry++;
if (_fs_retry > 3) { ota_flash_fail(OTA_ERR_ADDR_FAIL); return; }
_fs = OTA_FLASH_ADDR; /* 重发地址帧 */
}
return;
}
if (_fs == OTA_FLASH_DATA) {
/* 从 W25Qxx 读一块 → 组 A7 帧 → 发 (停等 ACK) */
uint32_t read_off = _fs_sent;
uint32_t remain = _meta.size - read_off;
uint16_t datalen = (remain > OTA_FLASH_BLOCK) ? OTA_FLASH_BLOCK : (uint16_t)remain;
SPI_Flash_Read(_ota_buf.frame + 5, _slot_base + read_off, datalen);
ota_send_9f((uint8_t)(_fs_sub & 0xFF), (uint8_t)(_fs_sub >> 8), 0xA7,
_ota_buf.frame + 5, datalen);
_fs = OTA_FLASH_WAIT_ACK;
_fs_start_ms = now;
PRINT("OTA: sent A7 sub=%u off=%lu datalen=%u\n",
_fs_sub, (unsigned long)read_off, datalen);
return;
}
if (_fs == OTA_FLASH_WAIT_ACK) {
if (now - _fs_start_ms > 1000u) {
_fs_retry++;
if (_fs_retry > 3) { ota_flash_fail(OTA_ERR_DATA_ACK_LIMIT); return; }
_fs = OTA_FLASH_DATA; /* 重发同块 */
}
return;
}
}
@@ -27,6 +27,7 @@
#include "tcp_json_srv.h"
#include "loop_uart_proto.h"
#include "iot_mqtt_srv.h"
#include "ota_srv.h"
#include "fault_diag.h"
/*********************************************************************
@@ -274,6 +275,7 @@ void Main_Circulation(void)
FAULT_MARKER(MK_UART_SRV_IN);
uart_srv();
FAULT_MARKER(MK_UART_SRV_OUT);
ota_poll(); /* OTA 刷写状态机 (V1.08): 非阻塞 tick 驱动, 先消费 UART2 ACK 再推进 */
snap_flush(); /* 快照落盘: 无条件挂主循环 (原在 iot_enable 分支, TCP 模式不落盘) */
FAULT_MARKER(MK_POLL_BLE_IN);
poll_dbn_ble();
@@ -328,6 +330,8 @@ int main(void)
PRINT("INIT: storage ok\n");
offlog_init();
PRINT("INIT: offlog ok\n");
ota_init(); /* OTA 元数据恢复 (V1.08): 下载中断续传 / 刷写中断标记失败 */
PRINT("INIT: ota ok\n");
/* 快照区: 延后初始化 (2026-08-17) — snap_init 移到主循环 3s 后
(snap_delayed_init), SPI (+ ~80ms);
3s snap_enqueue/snap_flush !_ready */
@@ -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;
@@ -11,6 +11,7 @@
#include "loop_uart_proto.h"
#include <string.h>
#include "dbn_ble_srv.h"
#include "ota_srv.h"
void USART1_IRQHandler(void) __attribute__((interrupt("WCH-Interrupt-fast")));
void USART2_IRQHandler(void) __attribute__((interrupt("WCH-Interrupt-fast")));
@@ -291,10 +292,14 @@ void uart_srv(void)
}
}
else{
/* OTA 模式 (2026-08-19): Loop bootloader 回的 0x9F
(back_9F_pre_ok / addr_ok / data ACK) BLE ,
/* OTA 模式: Loop bootloader 回的 0x9F ACK 帧 (pre_ok/addr_ok/data ACK) 分发:
- OTA (g_ota_flash_active, V1.08) ota_srv
- BLE (, V1.02.03) BLE
(0xA7 WITH_BACK) ACK */
if(g_flag_bt_state){
if(g_ota_flash_active){
ota_flash_feed_ack(g_pkg_uart_2.pkg, g_pkg_uart_2.offset);
}
else if(g_flag_bt_state){
g_flag_notify_temp = set_response_tran_to_notify(g_pkg_uart_2.pkg, g_pkg_uart_2.offset, &g_notify_buftemp);
}
/* OTA 模式同样清 flag — 残留帧会在 OTA 结束后被误处理 */
+234
View File
@@ -4,6 +4,236 @@
>
> 项目定位: DLD960 通信板 — BLE 配网、TCP JSON 协议服务、Loop MCU 串口桥接
## 2026-08-21 — MQTT 协议 V1.10 代码落地:网络上报携带地感版本(loop_ver/loop_hw_ver
> **固件版本:归入 vd960DBN V1.02.05 发布**2026-08-21 升版,cmcng.h 三段式一致:FIRMWARE_VER="1.02.05" + MAIN=1/SUB=2/SUBSUB=5)。V1.02.05 涵盖 8-20 联网稳定性修复系列(SocketSend 0x11 / OTA hex 空格 / union 合并 / 刷写状态语义)+ 本次 V1.10 协议实现。
### 背景
协议 V1.10`DLD960_IoT_MQTT协议.md`commit 3be9e50)三处新增,本次为固件实现:
1. `dev_info_query` 响应 data 补 `loop_ver` / `loop_hw_ver`(地感 Loop 版本,0x4A 缓存值)
2. `initialize` 上报同样携带(尽力,可为空)
3. 新增 §4.25 `loop_version_query` 命令:实时查询 Loop 版本,配合远程 OTA 升级前后核对
### 改动
| 位置 | 内容 |
|------|------|
| `loop_uart_proto.h/c` | 新增 `LoopVerCache` 结构 + 全局 `g_lup_ver_cache`0x4A 查询结果缓存);`lup_cache_from_info()` 解析结果写缓存、`lup_refresh_version_cache()` 后台刷新(仅命令通道空闲时发 0x4A) |
| `iot_mqtt_srv.c` dev_info_query | data 补 `loop_ver` / `loop_hw_ver`(缓存格式化字符串,查询未完成/失败为空串) |
| `iot_mqtt_srv.c` initialize | data 补 `loop_ver` / `loop_hw_ver`(尽力携带,可为空) |
| `iot_mqtt_srv.c` 新命令 `loop_version_query` | 0x4A 异步发起 + 暂存 msg_id / deadline(300ms)`iot_verq_poll()``g_lup_cmd.state == RESPONSE_READY` 时解析回包(code=0 + loop_ver/loop_hw_ver/version_str)并同步刷新缓存;TIMEOUT/超时回 code=5loop_ver 保持缓存值) |
| `iot_mqtt_srv.c` 后台刷新 | 上电 `iot_mqtt_init()` 置 dirty + OTA done`iot_ota_report_result ok=1`)置 dirty;poll 在命令通道空闲时自动发 0x4A 刷新缓存 |
### 关键设计
- **版本来源 = 0x4A 实时查询**`lup_parse_version`),不是 OTA 元数据 `version`——那是"目标版本"(审计用),0x4A 才是 Loop 当前真实固件版本
- **缓存值语义**`dev_info_query`/`initialize` 带缓存值(上电自动查一次 + OTA done 后刷新);MQTT 命令处理是同步回包,不能干等 0x4A 异步响应,故查询未完成/失败回空串;平台要最新版本走 `loop_version_query` 实时查询
- **异步回包**:与 TCP `json_check_pending` 同模式——暂存 msg_id(回包 topic 固定 `dld960/{sn}/dev`,无需存 topic),等 `g_lup_cmd.state` 状态机推进
- **单命令通道互斥**`g_lup_cmd` 全局唯一,TCP/MQTT/后台刷新共用;后发覆盖、先发者超时(低频命令,理论无冲突)
### 验证
- gcc 隔离单测 `tests/test_iot_loop_ver.c` **44 断言全过**
- 版本帧解析:正常帧(Status/Hard=1.0.0/Soft=1.2.3+ 错误路径(len<10 / bad magic / bad cmd / LEN<7
- 缓存:`lup_cache_from_info` 写入、`iot_loop_ver_str`/`iot_loop_hw_ver_str` 格式化(含无效缓存空串、NULL 防护)
- verq_poll 命令成功回包:code=0 + 三字段 + msg_id 回显 + topic 正确 + 缓存同步刷新 + 通道释放
- 超时回包:code=5 + `no response` + 缓存值保留
- 后台刷新:dirty=1+IDLE 发起查询(不设 pending)、通道忙不消费、响应消费更新缓存、残留超时清理
- `check_c_balance.py` 三个改动文件语法平衡 OK
- **待板上验证**0x4A 在 MQTT 工作期间与 `loop_data`/事件上报共用 UART2 总线的时序(查询是低频命令,理论无冲突);OTA done 后缓存刷新(Loop 复位时间窗内查询可能超时,可接受——平台可用 `loop_version_query` 主动核对)
---
## 2026-08-20 — 联网 SocketSend 0x11 死循环修复:发送退避 + SocketCreat 失败中止
### 现象(板级)
收 report_config 后 loop_data606B)发送失败:`SocketSend FAIL ret=0x11 sent=0/606` → 立即重试 10 次无效 → 3 次强制断连 → 重连 SocketCreat 返回 **0x1D(ISCONN)** + Connect 0x17(CLSD) → TCP connect timeout 死循环,永久失联。
### 根因
1. **WCHNET_NUM_TCP_SEG = WCHNET_NUM_TCP×2 = 2**(RAM 瘦身改小):TCP 发送段缓冲仅 2 个,重传队列占用即无缓冲 → 606B 发送 `WCHNET_ERR_MEM(0x11)`
2. **iot_mqtt_send 失败处理过激**:0x11 是瞬时(SEG 被占),却立即重试 10 次(无退避,WCHNET 没时间释放)+ 3 次就强制断连
3. **重连缺陷**`WCHNET_CreateTcpMqttSocket` 里 SocketCreat 失败(0x1D ISCONN,旧 socket 未清干净)后**仍继续 Connect**mStopIfError 只打印不中止)→ 无效 socket 等超时 → 死循环;且强制重连时 `SocketId_TCP` 未置 0xFFSocketCreat 沿用旧 id
### 修复
| 位置 | 改动 |
|------|------|
| `iot_mqtt_srv.c` iot_mqtt_send | 0x11 时 **200ms 退避重试**(等 WCHNET 释放 SEG,最多 10 次×200ms≈2s),不立即断连;非 MEM 错误立即重试 |
| `iot_mqtt_srv.c` 强制重连 | 补 `SocketId_TCP = 0xFF`(防 SocketCreat 沿用旧 id |
| `net_srv.c` WCHNET_CreateTcpMqttSocket | SocketCreat 失败 → 打印 + `SocketId_TCP=0xFF` + **return 不 Connect**,由上层指数退避重试 |
### 验证
- 语法 0 错误
- **待板级确认**:重发场景(瞬时 0x11)应自动恢复不断连;真断连重连失败应退避而非死循环
---
## 2026-08-20 — OTA 下载失败修复:hex 提取不兼容 json.dumps 空格(单片 CRC 全错)
### 现象(板级)
DBNMQTTool 下载第一片即失败:`ota_data code=1 crc/gap error`received=0,重试 4 次全败。
### 根因
工具 `json.dumps` 默认**带空格**`"data": "hex..."`),设备端手工 `strstr(json, "\"data\":\"")` 找**无空格**格式 → 匹配不到 → hexbuf 空 → `ota_hex_decode` 失败 → code=1。其他命令走 `simple_parse_json`(容错空格)不受影响,只有 OTA 的 hex 提取踩坑。
### 修复
- `ota_srv.c/h` 新增 `ota_json_extract_hex()`:扫描 `"data"` 键后跳过任意空格/冒号/引号,兼容 `"data":"..."``"data": "..."` 两种格式
- `iot_mqtt_srv.c` ota_data 分支改用该函数(删除 strstr 手工提取 + 残留 hp 变量)
- 单测新增 `test_json_extract_hex`:无空格/带空格(事故场景)/512 hex 满片/找不到,4 场景全过
### 教训
嵌入式 JSON 协议字段提取**统一用 simple_parse_json 或兼容空格的扫描**,不要 strstr 精确匹配——上位机 JSON 序列化器(json.dumps/不同语言库)的空格行为不可控。
---
## 2026-08-20 — 联网复位事故修复:OTA 命令缓冲合并 union(RAM 90% 栈余量不足)
### 现象(板级)
联网后 SUBACK → 平台下发 MQTT PUBLISHlen=197,即 `report_config` 时钟同步)→ **HardFault → NVIC_SystemReset**`RST_REASON: 0x10000000` = SFT 软件复位位,非 IWDG 0x20000000CH32V20x 默认 HardFault_Handler 即软件复位)→ 死循环复位。
### 根因
MRS 编译 RAM **90.02%**44248/48KB)逼近历史 .bss 挤栈红线。本次 OTA 实现 6 个命令分支各声明独立 `static char resp[256~400]` + `static char hexbuf[513]`(合计 ~2.5KB BSS)→ 栈余量被挤 → `iot_handle_publish` 深调用链(MQTT 解析 → simple_json → snprintfreport_config 分支本身还有局部 `data[512]`**栈溢出 → HardFault**。复现稳定(每次收 report_config 必崩)。
### 修复
| 项 | 改动 | 省 |
|----|------|-----|
| iot_mqtt_srv.c | 6 个 resp + hexbuf 合并为函数级 `static union _ota_io { resp[512]; hexbuf[513]; }`(互斥复用) | **~2KB BSS** |
| ota_srv.c | `_chunk_buf[256]` + `_fs_frame[254]` 合并 union(下载/校验与刷写帧互斥) | ~260B |
| ota_srv.c | 删无用 `_fs_block_crc` | 4B |
**教训**RAM 90% 环境下"局部栈改 static"是**伪优化**——BSS 增加 = 栈余量减少,深调用链照样溢出。正确方向是**合并复用缓冲减少总占用**(union),不是搬家。
### 验证
- 语法 0 错误(基线 interrupt 假阳性除外);gcc 隔离单测 10/10 PASS
- **待板级确认**:拉最新代码重编,RAM 应回 ~88%,联网收 report_config 不再复位
---
## 2026-08-20 — Loop MCU 远程 OTA 固件实现(MQTT V1.08ROADMAP P1.4 ①)
### 背景
协议设计稿 V1.01 拍板 + 并入 MQTT 协议 V1.08 + DBNMQTTool OTA 页签/协议单测先行。本次固件实现**先存后刷**:MQTT 分片下载 → W25Qxx OTA 暂存区(0x010000 起 512KB)→ 全镜像 CRC32 复核 → 本地 0x9F ISP 透传刷写 Loop MCUAT32F421)。
### 变更
| 文件 | 变更 |
|------|------|
| `ota_srv.c/h`(新增) | CRC32ISO-HDLC 逐位,零查表 RAM)、OtaMeta 元数据(双备份 0x010000/0x010200,节流 4KB flush)、会话状态机(begin/data/end/abort/flash)、本地刷写状态机(非阻塞 tick 驱动:A5→pre_ok→A6→addr_ok→A7×N 停等 ACK,1s 超时 ×3)、安全窗口(有车拒绝,force 跳过)、失败 event_report ota_error |
| `iot_mqtt_srv.c` | 命令分发加 6 个 ota_* 分支;会话静默(downloading/flashing 暂停 event_report 发送 + offlog/快照落盘,队列积压结束后补发);IOT_EVT_OTA_ERROR 事件类型 + `iot_evt_report_ota_error` + `iot_any_car` |
| `usart_biz.c` | uart_srv OTA 分支 ACK 分发:`g_ota_flash_active` 时交给 `ota_flash_feed_ack`(本地刷写),否则透传 BLE(现状) |
| `peripheral_main.c` | `ota_init()` 初始化 + `ota_poll()` 挂主循环(uart_srv 后,先消费 ACK 再推进) |
| `offlog.c/h` | 事件类型 OTA_START 0x60 / OTA_RESULT 0x61 + 包装函数(升级开始/结果审计留痕) |
### 关键参数
| 项 | 值 |
|----|-----|
| 下载单片 | 256B + 单片 CRC32hex 512 字符 < RECV_BUF 1024 |
| 镜像上限 | 96KBSlot 100KB - 4KB 边界余量) |
| 刷写块 | 248B0x9F LEN 为 uint8 → DATA ≤254Bbootloader 2KB 扇区累积写) |
| 分区 | 元数据 4KB + Slot A/B 各 100KB0x011000/0x02A000+ 预留 308KB |
| 断点续传 | 元数据 received 节流 4KB flush,掉电丢 ≤4KB(平台重发幂等,协议已定义) |
| 刷写窗口 | 64KB ≈ 265 块 × ~20ms ≈ 6~8s;非阻塞调度保 MQTT 保活/IWDG |
### 单测(gcc 隔离,`tests/test_ota_srv.c`mock NOR/UART2/时钟)
9 断言组全过:CRC32 标准向量 0xCBF43926 / hex 解码 / 元数据双备份恢复 / 会话流 20 片→ready / 断点续传(16 片 flush 后精确续传)/ 幂等·乱序·单片 CRC / 刷写全流程(21 块 A7 逐块 ACK)/ 有车拒绝 + force 跳过 / pre_ok 超时重试失败告警。
**抓到 2 个真 bug(单测价值实证)**:
1. **A7 块序号未递减**`_fs_sub` 每块 ACK 后不减 → bootloader 永远等不到末包(sub==1)→ 刷写卡死。修复:ACK 成功路径 `_fs_sub--`
2. **重发时 retry 清零**START/ADDR 分支 `_fs_retry=0` 每次重发清零 → 超时重试永不失败。修复:重发保持计数(首发由 ota_cmd_flash 置 0ACK 成功由 feed_ack 清 0)。
### 待板上验证
- UART2 TX 阻塞轮询(248B ≈ 13ms)与主循环时序、擦除 45ms×N 分散阻塞对 MQTT 保活影响
- Loop 实际 bin 大小(验证 96KB 余量)、bootloader 真实 ACK 时序
- 会话静默后 event_report 补发行为(队列积压恢复)
---
## 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 +1646,10 @@ TCP 超时要等 ~2 分钟才触发 `SINT_STAT_TIM_OUT`,
| 版本 | 时间 | 说明 |
|------|------|------|
| V4.8 | 2026-08-21 | MQTT 协议 V1.10 固件实现: dev_info_query/initialize 补 loop_ver/loop_hw_ver + 新命令 loop_version_query 异步回包 + 版本缓存(上电/OTA done 刷新), 单测 44 例 |
| 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 对齐), 工具禁用未实现按钮 |
+5
View File
@@ -0,0 +1,5 @@
/* mock CONFIG.h — ota_srv.c 隔离单测用 (gcc, 无 WCH 环境) */
#ifndef __MOCK_CONFIG_H__
#define __MOCK_CONFIG_H__
#endif
+22
View File
@@ -0,0 +1,22 @@
/* mock cmcng.h — ota_srv.c 隔离单测用 (gcc, 无 WCH 环境) */
#ifndef __MOCK_CMCNG_H__
#define __MOCK_CMCNG_H__
#include <stdint.h>
#include <stdio.h>
typedef struct {
uint32_t flag;
uint32_t timeout;
uint32_t tick;
} Flag_Counter;
extern Flag_Counter g_flag_counter_key;
extern Flag_Counter g_flag_counter_ota;
uint32_t mstick(void);
void UART2_SendString(uint8_t *buf, uint16_t len);
#define PRINT(fmt, ...) printf(fmt, ##__VA_ARGS__)
#endif
+463
View File
@@ -0,0 +1,463 @@
/*
* test_iot_loop_ver.c V1.10 : 0x4A + + MQTT
*
* :
* 1. lup_parse_version: 0x4A (/)
* 2. lup_cache_from_info / iot_loop_ver_str / iot_loop_hw_ver_str:
* 3. iot_verq_poll :
* - pending + RESPONSE_READY code=0 + +
* - pending + TIMEOUT code=5
* - : dirty=1 + IDLE 0x4A ( pending)
* - : !dirty + RESPONSE_READY +
* - : TIMEOUT IDLE
*
* : gcc -o test_iot_loop_ver test_iot_loop_ver.c && ./test_iot_loop_ver
*/
#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <stdlib.h>
/* ---- mock: 最小依赖 ---- */
#define LUP_MAGIC 0x7F
#define LUP_CMD_GET_VERSION 0x4A
#define LUP_MAX_PKG_LEN 70
#define IOT_MQTT_TOPIC_MAX_LEN 64
typedef struct {
uint8_t status;
uint8_t hard_main, hard_sub, hard_ssub;
uint8_t soft_main, soft_sub, soft_ssub;
char version_str[32];
} LUP_VersionInfo;
typedef struct {
uint8_t valid;
uint8_t hard_main, hard_sub, hard_ssub;
uint8_t soft_main, soft_sub, soft_ssub;
} LoopVerCache;
typedef enum {
LUP_STATE_IDLE = 0,
LUP_STATE_WAIT_RESPONSE,
LUP_STATE_RESPONSE_READY,
LUP_STATE_TIMEOUT
} LUP_CmdState;
typedef struct {
uint8_t pending_cmd;
uint32_t send_tick;
uint32_t timeout_ms;
LUP_CmdState state;
uint8_t resp_buf[LUP_MAX_PKG_LEN];
uint16_t resp_len;
} LUP_CmdTracker;
/* ---- mock 全局 ---- */
static LUP_CmdTracker g_lup_cmd;
static LoopVerCache g_lup_ver_cache;
static char g_iot_dev_serial[16] = "A1B2C3D4E5F6";
/* ---- mock 时间 ---- */
static uint32_t g_mock_ms = 100000;
static uint32_t mstick(void) { return g_mock_ms; }
static uint32_t dev_time_now(void) { return 1719000008UL; }
/* ---- mock 发布捕获 ---- */
static char g_pub_topic[64];
static char g_pub_payload[512];
static uint16_t g_pub_len;
static int g_pub_count;
static int g_getver_calls;
static int iot_mqtt_publish(const char *topic, const char *payload,
uint16_t payload_len, uint8_t qos)
{
(void)qos;
g_pub_count++;
snprintf(g_pub_topic, sizeof(g_pub_topic), "%s", topic ? topic : "");
if (payload_len < sizeof(g_pub_payload)) {
memcpy(g_pub_payload, payload, payload_len);
g_pub_payload[payload_len] = '\0';
} else {
memcpy(g_pub_payload, payload, sizeof(g_pub_payload) - 1);
g_pub_payload[sizeof(g_pub_payload) - 1] = '\0';
}
g_pub_len = payload_len;
return 0;
}
/* mock lup_send_get_version: 模拟 lup_cmd_begin 的状态推进 */
static void lup_send_get_version(void)
{
g_getver_calls++;
g_lup_cmd.pending_cmd = LUP_CMD_GET_VERSION;
g_lup_cmd.state = LUP_STATE_WAIT_RESPONSE;
g_lup_cmd.resp_len = 0;
memset(g_lup_cmd.resp_buf, 0, sizeof(g_lup_cmd.resp_buf));
}
/* mock lup_cmd_done: 释放命令通道 */
static void lup_cmd_done(void)
{
g_lup_cmd.pending_cmd = 0;
g_lup_cmd.state = LUP_STATE_IDLE;
g_lup_cmd.resp_len = 0;
}
#define PRINT(...) printf(__VA_ARGS__)
/* ---- 被测静态状态 (从 iot_mqtt_srv.c 提取的变量, 单测转为可访问全局) ---- */
static uint8_t _iot_verq_pending;
static uint32_t _iot_verq_msg_id;
static uint32_t _iot_verq_deadline;
static uint8_t _iot_ver_cache_dirty;
/* ================= 嵌入被测函数 (从真实源码提取) ================= */
int lup_parse_version(const uint8_t *pkg, uint16_t len, LUP_VersionInfo *info)
{
if (len < 10) return -1; // 1+3+7+2 = 13 min
// pkg layout: [7F][Addr=0][LEN=8][CMD=0x4A][Status...7bytes][XOR][SUM]
if (pkg[0] != LUP_MAGIC || pkg[3] != LUP_CMD_GET_VERSION) return -2;
if (pkg[2] < 7) return -3; // LEN should be >= 7
const uint8_t *val = pkg + 4; // skip magic + header(3)
info->status = val[0];
info->hard_main = val[1];
info->hard_sub = val[2];
info->hard_ssub = val[3];
info->soft_main = val[4];
info->soft_sub = val[5];
info->soft_ssub = val[6];
return 0;
}
void lup_cache_from_info(const LUP_VersionInfo *info)
{
if (!info) return;
g_lup_ver_cache.valid = 1;
g_lup_ver_cache.hard_main = info->hard_main;
g_lup_ver_cache.hard_sub = info->hard_sub;
g_lup_ver_cache.hard_ssub = info->hard_ssub;
g_lup_ver_cache.soft_main = info->soft_main;
g_lup_ver_cache.soft_sub = info->soft_sub;
g_lup_ver_cache.soft_ssub = info->soft_ssub;
}
static void iot_loop_ver_str(char *out, uint16_t out_len)
{
if (!g_lup_ver_cache.valid || !out || out_len == 0) { if (out && out_len) out[0] = '\0'; return; }
snprintf(out, out_len, "%d.%d.%d",
g_lup_ver_cache.soft_main, g_lup_ver_cache.soft_sub, g_lup_ver_cache.soft_ssub);
}
static void iot_loop_hw_ver_str(char *out, uint16_t out_len)
{
if (!g_lup_ver_cache.valid || !out || out_len == 0) { if (out && out_len) out[0] = '\0'; return; }
snprintf(out, out_len, "%d.%d.%d",
g_lup_ver_cache.hard_main, g_lup_ver_cache.hard_sub, g_lup_ver_cache.hard_ssub);
}
static void iot_verq_publish(const char *payload, uint16_t len)
{
char topic[IOT_MQTT_TOPIC_MAX_LEN];
snprintf(topic, sizeof(topic), "dld960/%s/dev", g_iot_dev_serial);
iot_mqtt_publish(topic, payload, len, 1);
}
static void iot_verq_poll(void)
{
if (_iot_verq_pending) {
/* 命令查询在途: 等 0x4A 响应或超时 */
if (g_lup_cmd.state == LUP_STATE_RESPONSE_READY) {
LUP_VersionInfo info;
char resp[320];
char ver_str[16], hw_str[16], vs_str[64];
int ok = 0;
memset(&info, 0, sizeof(info));
if (lup_parse_version(g_lup_cmd.resp_buf, g_lup_cmd.resp_len, &info) == 0) {
lup_cache_from_info(&info); /* 同步刷新缓存 */
snprintf(ver_str, sizeof(ver_str), "%d.%d.%d",
info.soft_main, info.soft_sub, info.soft_ssub);
snprintf(hw_str, sizeof(hw_str), "%d.%d.%d",
info.hard_main, info.hard_sub, info.hard_ssub);
snprintf(vs_str, sizeof(vs_str), "V%s (HW:%s)", ver_str, hw_str);
snprintf(resp, sizeof(resp),
"{\"msg_id\":%lu,\"cmd\":\"loop_version_query\",\"ts\":%lu,\"code\":0,"
"\"msg\":\"success\","
"\"data\":{\"loop_ver\":\"%s\",\"loop_hw_ver\":\"%s\",\"version_str\":\"%s\"}}",
_iot_verq_msg_id, dev_time_now(), ver_str, hw_str, vs_str);
ok = 1;
} else {
char lv[16], lhv[16];
iot_loop_ver_str(lv, sizeof(lv));
iot_loop_hw_ver_str(lhv, sizeof(lhv));
snprintf(resp, sizeof(resp),
"{\"msg_id\":%lu,\"cmd\":\"loop_version_query\",\"ts\":%lu,\"code\":5,"
"\"msg\":\"parse error\","
"\"data\":{\"loop_ver\":\"%s\",\"loop_hw_ver\":\"%s\",\"version_str\":\"\"}}",
_iot_verq_msg_id, dev_time_now(), lv, lhv);
}
lup_cmd_done();
_iot_verq_pending = 0;
iot_verq_publish(resp, strlen(resp));
PRINT("IOT: loop_version_query resp %s\n", ok ? "ok" : "parse error");
} else if (g_lup_cmd.state == LUP_STATE_TIMEOUT || mstick() > _iot_verq_deadline) {
char resp[320];
char lv[16], lhv[16];
iot_loop_ver_str(lv, sizeof(lv));
iot_loop_hw_ver_str(lhv, sizeof(lhv));
snprintf(resp, sizeof(resp),
"{\"msg_id\":%lu,\"cmd\":\"loop_version_query\",\"ts\":%lu,\"code\":5,"
"\"msg\":\"no response\","
"\"data\":{\"loop_ver\":\"%s\",\"loop_hw_ver\":\"%s\",\"version_str\":\"\"}}",
_iot_verq_msg_id, dev_time_now(), lv, lhv);
lup_cmd_done();
_iot_verq_pending = 0;
iot_verq_publish(resp, strlen(resp));
PRINT("IOT: loop_version_query timeout\n");
}
/* 其它 state (WAIT_RESPONSE) 继续等 */
} else {
/* 后台缓存刷新: 上电 / OTA done 后置 dirty, 命令通道空闲时发 0x4A */
if (_iot_ver_cache_dirty && g_lup_cmd.state == LUP_STATE_IDLE) {
lup_send_get_version();
_iot_ver_cache_dirty = 0;
PRINT("IOT: loop version cache refresh query\n");
} else if (!_iot_ver_cache_dirty && g_lup_cmd.state == LUP_STATE_RESPONSE_READY) {
/* 后台查询响应 (无命令 pending) → 更新缓存 + 释放通道 */
LUP_VersionInfo info;
memset(&info, 0, sizeof(info));
if (lup_parse_version(g_lup_cmd.resp_buf, g_lup_cmd.resp_len, &info) == 0) {
lup_cache_from_info(&info);
PRINT("IOT: loop version cache refreshed %d.%d.%d (HW:%d.%d.%d)\n",
info.soft_main, info.soft_sub, info.soft_ssub,
info.hard_main, info.hard_sub, info.hard_ssub);
}
lup_cmd_done();
} else if (g_lup_cmd.state == LUP_STATE_TIMEOUT) {
lup_cmd_done(); /* 清残留超时态 (后台查询失败或被其它命令覆盖) */
}
}
}
/* ================= 测试工具 ================= */
static int g_pass = 0, g_fail = 0;
#define CHECK(cond, msg) do { \
if (cond) { g_pass++; printf(" PASS: %s\n", msg); } \
else { g_fail++; printf(" FAIL: %s (line %d)\n", msg, __LINE__); } \
} while (0)
static void reset_state(void)
{
memset(&g_lup_cmd, 0, sizeof(g_lup_cmd));
memset(&g_lup_ver_cache, 0, sizeof(g_lup_ver_cache));
memset(&g_pub_payload, 0, sizeof(g_pub_payload));
g_pub_count = 0;
g_pub_len = 0;
g_getver_calls = 0;
_iot_verq_pending = 0;
_iot_verq_msg_id = 0;
_iot_verq_deadline = 0;
_iot_ver_cache_dirty = 0;
g_mock_ms = 100000;
}
/* 构造 0x4A 响应帧: 7F 00 LEN 4A [status][hard_m][hard_s][hard_ss][soft_m][soft_s][soft_ss] [xor][sum]
(checksum lup_parse_version , 0) */
static void build_version_frame(uint8_t *pkg, uint16_t *len,
uint8_t status, uint8_t hm, uint8_t hs, uint8_t hss,
uint8_t sm, uint8_t ss, uint8_t sss)
{
pkg[0] = LUP_MAGIC;
pkg[1] = 0x00;
pkg[2] = 0x08;
pkg[3] = LUP_CMD_GET_VERSION;
pkg[4] = status;
pkg[5] = hm; pkg[6] = hs; pkg[7] = hss;
pkg[8] = sm; pkg[9] = ss; pkg[10] = sss;
pkg[11] = 0; pkg[12] = 0; /* XOR/SUM 不校验 */
*len = 13;
}
static void test_parse_version(void)
{
printf("[1] lup_parse_version\n");
uint8_t pkg[16];
uint16_t len;
LUP_VersionInfo info;
build_version_frame(pkg, &len, 0x00, 1,0,0, 1,2,3);
CHECK(lup_parse_version(pkg, len, &info) == 0, "valid frame returns 0");
CHECK(info.status == 0x00, "status=0");
CHECK(info.hard_main == 1 && info.hard_sub == 0 && info.hard_ssub == 0, "hard=1.0.0");
CHECK(info.soft_main == 1 && info.soft_sub == 2 && info.soft_ssub == 3, "soft=1.2.3");
/* 错误路径 */
CHECK(lup_parse_version(pkg, 9, &info) == -1, "len<10 -> -1");
pkg[0] = 0x00;
CHECK(lup_parse_version(pkg, len, &info) == -2, "bad magic -> -2");
pkg[0] = LUP_MAGIC;
pkg[3] = 0x55;
CHECK(lup_parse_version(pkg, len, &info) == -2, "bad cmd -> -2");
pkg[3] = LUP_CMD_GET_VERSION;
pkg[2] = 0x05;
CHECK(lup_parse_version(pkg, len, &info) == -3, "LEN<7 -> -3");
}
static void test_cache(void)
{
printf("[2] 版本缓存\n");
LUP_VersionInfo info;
char out[24];
memset(&info, 0, sizeof(info));
info.hard_main = 1; info.hard_sub = 0; info.hard_ssub = 0;
info.soft_main = 1; info.soft_sub = 2; info.soft_ssub = 3;
lup_cache_from_info(&info);
CHECK(g_lup_ver_cache.valid == 1, "cache valid after write");
CHECK(g_lup_ver_cache.soft_main == 1 && g_lup_ver_cache.soft_ssub == 3, "cache soft fields");
iot_loop_ver_str(out, sizeof(out));
CHECK(strcmp(out, "1.2.3") == 0, "loop_ver str = 1.2.3");
iot_loop_hw_ver_str(out, sizeof(out));
CHECK(strcmp(out, "1.0.0") == 0, "loop_hw_ver str = 1.0.0");
/* 无效缓存 → 空串 */
memset(&g_lup_ver_cache, 0, sizeof(g_lup_ver_cache));
iot_loop_ver_str(out, sizeof(out));
CHECK(strcmp(out, "") == 0, "invalid cache -> empty str");
iot_loop_hw_ver_str(out, sizeof(out));
CHECK(strcmp(out, "") == 0, "invalid hw cache -> empty str");
/* NULL/0 长度防护 */
iot_loop_ver_str(NULL, 0);
CHECK(1, "NULL guard no crash");
}
static void test_verq_success(void)
{
printf("[3] verq_poll: 命令成功回包\n");
reset_state();
uint8_t pkg[16];
uint16_t len;
build_version_frame(pkg, &len, 0x00, 1,0,0, 1,2,3);
memcpy(g_lup_cmd.resp_buf, pkg, len);
g_lup_cmd.resp_len = len;
g_lup_cmd.state = LUP_STATE_RESPONSE_READY;
_iot_verq_pending = 1;
_iot_verq_msg_id = 407;
_iot_verq_deadline = g_mock_ms + 300;
iot_verq_poll();
CHECK(g_pub_count == 1, "publish once");
CHECK(strstr(g_pub_payload, "\"msg_id\":407") != NULL, "msg_id echoed");
CHECK(strstr(g_pub_payload, "\"code\":0") != NULL, "code=0");
CHECK(strstr(g_pub_payload, "\"loop_ver\":\"1.2.3\"") != NULL, "loop_ver=1.2.3");
CHECK(strstr(g_pub_payload, "\"loop_hw_ver\":\"1.0.0\"") != NULL, "loop_hw_ver=1.0.0");
CHECK(strstr(g_pub_payload, "\"version_str\":\"V1.2.3 (HW:1.0.0)\"") != NULL, "version_str format");
CHECK(strcmp(g_pub_topic, "dld960/A1B2C3D4E5F6/dev") == 0, "resp topic");
CHECK(_iot_verq_pending == 0, "pending cleared");
CHECK(g_lup_cmd.state == LUP_STATE_IDLE, "cmd channel released");
CHECK(g_lup_ver_cache.valid == 1, "cache refreshed by command resp");
}
static void test_verq_timeout(void)
{
printf("[4] verq_poll: 命令超时\n");
reset_state();
/* 缓存先有效, 超时回包应携带缓存值 */
g_lup_ver_cache.valid = 1;
g_lup_ver_cache.soft_main = 9; g_lup_ver_cache.soft_sub = 9; g_lup_ver_cache.soft_ssub = 9;
g_lup_ver_cache.hard_main = 8; g_lup_ver_cache.hard_sub = 8; g_lup_ver_cache.hard_ssub = 8;
g_lup_cmd.state = LUP_STATE_WAIT_RESPONSE; /* 还挂着 */
_iot_verq_pending = 1;
_iot_verq_msg_id = 408;
_iot_verq_deadline = g_mock_ms + 300;
g_mock_ms += 400; /* 超过 deadline */
iot_verq_poll();
CHECK(g_pub_count == 1, "timeout publish once");
CHECK(strstr(g_pub_payload, "\"code\":5") != NULL, "code=5");
CHECK(strstr(g_pub_payload, "\"msg\":\"no response\"") != NULL, "msg=no response");
CHECK(strstr(g_pub_payload, "\"loop_ver\":\"9.9.9\"") != NULL, "cache value kept in resp");
CHECK(_iot_verq_pending == 0, "pending cleared");
CHECK(g_lup_cmd.state == LUP_STATE_IDLE, "cmd released after timeout");
/* 直接 TIMEOUT 态 */
reset_state();
g_lup_cmd.state = LUP_STATE_TIMEOUT;
_iot_verq_pending = 1;
_iot_verq_msg_id = 409;
_iot_verq_deadline = g_mock_ms + 300;
iot_verq_poll();
CHECK(g_pub_count == 1, "TIMEOUT state publish once");
CHECK(strstr(g_pub_payload, "\"code\":5") != NULL, "TIMEOUT state code=5");
}
static void test_bg_refresh(void)
{
printf("[5] verq_poll: 后台缓存刷新\n");
reset_state();
/* 5a. dirty=1 + IDLE → 发起查询, 不设 pending */
_iot_ver_cache_dirty = 1;
g_lup_cmd.state = LUP_STATE_IDLE;
iot_verq_poll();
CHECK(g_getver_calls == 1, "bg query issued");
CHECK(_iot_ver_cache_dirty == 0, "dirty cleared");
CHECK(_iot_verq_pending == 0, "no command pending set");
CHECK(g_pub_count == 0, "no publish for bg query");
/* 5b. 通道忙 (WAIT_RESPONSE) 时 dirty 不消费 */
{
int base = g_getver_calls;
_iot_ver_cache_dirty = 1;
g_lup_cmd.state = LUP_STATE_WAIT_RESPONSE;
iot_verq_poll();
CHECK(g_getver_calls == base, "busy channel -> no query");
CHECK(_iot_ver_cache_dirty == 1, "dirty kept while busy");
}
/* 5c. 后台响应消费: !dirty + RESPONSE_READY → 更新缓存 + 释放, 不回包 */
reset_state();
uint8_t pkg[16];
uint16_t len;
build_version_frame(pkg, &len, 0x00, 2,0,0, 3,4,5);
memcpy(g_lup_cmd.resp_buf, pkg, len);
g_lup_cmd.resp_len = len;
g_lup_cmd.state = LUP_STATE_RESPONSE_READY;
_iot_ver_cache_dirty = 0;
iot_verq_poll();
CHECK(g_pub_count == 0, "bg resp no publish");
CHECK(g_lup_ver_cache.valid == 1, "bg resp cache valid");
CHECK(g_lup_ver_cache.soft_main == 3 && g_lup_ver_cache.soft_ssub == 5, "bg resp soft=3.4.5");
CHECK(g_lup_cmd.state == LUP_STATE_IDLE, "bg resp channel released");
/* 5d. 残留超时清理 */
reset_state();
g_lup_cmd.state = LUP_STATE_TIMEOUT;
iot_verq_poll();
CHECK(g_lup_cmd.state == LUP_STATE_IDLE, "stale timeout cleaned");
}
int main(void)
{
printf("=== test_iot_loop_ver: V1.10 版本缓存 + 异步回包 ===\n");
reset_state();
test_parse_version();
test_cache();
test_verq_success();
test_verq_timeout();
test_bg_refresh();
printf("\nRESULT: %d passed, %d failed\n", g_pass, g_fail);
return g_fail ? 1 : 0;
}
+547
View File
@@ -0,0 +1,547 @@
/*
* ota_srv.c (gcc, WCH )
*
* : mock CONFIG.h/cmcng.h + mock (NOR flash / UART2 /
* / offlog ) #include ../APP/ota_srv.c (,
* static ) + +
*
* :
* gcc -I tests/ota_mock -I APP/include -I APP -o /tmp/test_ota_srv \
* tests/test_ota_srv.c
* : /tmp/test_ota_srv
*/
#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <assert.h>
#include "cmcng.h" /* mock: Flag_Counter / PRINT / mstick / UART2_SendString */
/* ============================================================================
* mock
* ============================================================================*/
Flag_Counter g_flag_counter_key = {0, 0, 0};
Flag_Counter g_flag_counter_ota = {0, 0, 0};
static uint32_t _mock_ms = 0;
uint32_t mstick(void) { return _mock_ms; }
static void mock_advance(uint32_t ms) { _mock_ms += ms; }
/* UART2 发送记录: 保存最近一帧 */
static uint8_t _tx_buf[512];
static uint16_t _tx_len = 0;
static uint16_t _tx_count = 0;
void UART2_SendString(uint8_t *buf, uint16_t len)
{
_tx_len = (len <= sizeof(_tx_buf)) ? len : (uint16_t)sizeof(_tx_buf);
memcpy(_tx_buf, buf, _tx_len);
_tx_count++;
}
static void tx_reset(void) { _tx_len = 0; _tx_count = 0; }
/* ---- mock NOR flash (W25Q32 语义: 写=AND, 擦=0xFF) ---- */
#define MOCK_FLASH_SIZE (4 * 1024 * 1024)
static uint8_t flash[MOCK_FLASH_SIZE];
static void flash_init(void) { memset(flash, 0xFF, sizeof(flash)); }
void SPI_Flash_Read(uint8_t *pBuffer, uint32_t ReadAddr, uint16_t size)
{
assert(ReadAddr + size <= MOCK_FLASH_SIZE);
memcpy(pBuffer, &flash[ReadAddr], size);
}
void SPI_Flash_Write_NoCheck(uint8_t *pBuffer, uint32_t WriteAddr, uint16_t size)
{
uint16_t i;
assert(WriteAddr + size <= MOCK_FLASH_SIZE);
for (i = 0; i < size; i++) flash[WriteAddr + i] &= pBuffer[i];
}
void SPI_Flash_Erase_Sector(uint32_t Dst_Addr)
{
assert(Dst_Addr * 4096UL + 4096UL <= MOCK_FLASH_SIZE);
memset(&flash[Dst_Addr * 4096UL], 0xFF, 4096);
}
/* 与 storage.c 语义一致: 目标区非 0xFF → 擦扇区 → 读-改-写保留同扇区其他数据 */
void SPI_Flash_Write(uint8_t *pBuffer, uint32_t WriteAddr, uint16_t size)
{
uint32_t secpos, secoff, secremain;
uint16_t i;
while (size > 0) {
secpos = WriteAddr / 4096;
secoff = WriteAddr % 4096;
secremain = 4096 - secoff;
if (size <= secremain) secremain = size;
/* 目标区是否全 0xFF */
{
uint8_t need_erase = 0;
for (i = 0; i < secremain; i++) {
if (flash[secpos * 4096 + secoff + i] != 0xFF) { need_erase = 1; break; }
}
if (need_erase) {
uint8_t sector[4096];
memcpy(sector, &flash[secpos * 4096], 4096);
for (i = 0; i < secremain; i++) sector[secoff + i] = pBuffer[i];
memset(&flash[secpos * 4096], 0xFF, 4096);
for (i = 0; i < 4096; i++) flash[secpos * 4096 + i] &= sector[i];
} else {
for (i = 0; i < secremain; i++) flash[WriteAddr + i] &= pBuffer[i];
}
}
pBuffer += secremain;
WriteAddr += (uint32_t)secremain;
size -= (uint16_t)secremain;
}
}
uint8_t SPI_Flash_ReadJEDEC_ID(void) { return 0x16; } /* W25Q32 */
/* ---- mock offlog (记录最近 OTA 事件) ---- */
static uint32_t _ota_start_cnt = 0, _ota_result_cnt = 0;
static uint32_t _last_ota_result = 0;
void offlog_evt(uint8_t type, const uint8_t *payload, uint8_t len) { (void)type; (void)payload; (void)len; }
void offlog_ota_start(uint8_t slot, uint32_t size) { _ota_start_cnt++; (void)slot; (void)size; }
void offlog_ota_result(uint32_t code) { _ota_result_cnt++; _last_ota_result = code; }
void offlog_boot(uint32_t r) { (void)r; }
/* ---- mock loop_uart_proto ---- */
void lup_frame_reset(void) { }
/* ---- mock iot_mqtt_srv ---- */
static uint8_t _mock_any_car = 0;
static uint32_t _ota_err_report = 0, _ota_err_cnt = 0;
static uint8_t _ota_rep_ok = 0xFF;
static uint32_t _ota_rep_err = 0, _ota_rep_cnt = 0;
uint8_t iot_any_car(void) { return _mock_any_car; }
void iot_evt_report_ota_error(uint32_t err) { _ota_err_report = err; _ota_err_cnt++; }
/* V1.09: ota_report 结果上报 mock (ok=1 done, ok=0 failed) */
void iot_ota_report_result(uint8_t ok, uint32_t err) { _ota_rep_ok = ok; _ota_rep_err = err; _ota_rep_cnt++; }
/* ============================================================================
* ota_srv.c (, static )
* ============================================================================*/
#include "../APP/ota_srv.c"
/* ============================================================================
*
* ============================================================================*/
#define CHK(cond) do { if (!(cond)) { \
printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); return 1; } } while (0)
/* 构造测试镜像 (5120B = 20 片) */
static void make_image(uint8_t *buf, uint32_t size)
{
uint32_t i;
for (i = 0; i < size; i++) buf[i] = (uint8_t)(i * 7 + 3);
}
static int test_crc32(void)
{
/* CRC-32/ISO-HDLC 标准向量 */
CHK(ota_crc32((const uint8_t *)"123456789", 9) == 0xCBF43926UL);
CHK(ota_crc32(NULL, 0) == 0x00000000UL);
printf("PASS test_crc32\n");
return 0;
}
static int test_hex_decode(void)
{
uint8_t out[4];
CHK(ota_hex_decode("0102aBff", out, 4) == 0);
CHK(out[0] == 0x01 && out[1] == 0x02 && out[2] == 0xAB && out[3] == 0xFF);
CHK(ota_hex_decode("01z2", out, 2) != 0); /* 非法字符 */
CHK(ota_hex_decode("010", out, 2) != 0); /* 长度不足 */
printf("PASS test_hex_decode\n");
return 0;
}
static int test_json_extract_hex(void)
{
char out[520];
/* 无空格 (紧凑格式) */
CHK(ota_json_extract_hex(
"{\"msg_id\":402,\"cmd\":\"ota_data\",\"ts\":1,\"data\":{\"target\":\"loop\",\"offset\":0,\"crc32\":1,\"data\":\"0102aBff\"}}",
out, sizeof(out)) == 8);
CHK(strcmp(out, "0102aBff") == 0);
/* 带空格 (json.dumps 默认) — 2026-08-20 事故场景 */
CHK(ota_json_extract_hex(
"{\"msg_id\": 402, \"cmd\": \"ota_data\", \"ts\": 1, \"data\": {\"target\": \"loop\", \"offset\": 0, \"crc32\": 1, \"data\": \"0102aBff\"}}",
out, sizeof(out)) == 8);
CHK(strcmp(out, "0102aBff") == 0);
/* 512 hex 满片 */
{
char json[700];
char hex[513];
int i, n;
for (i = 0; i < 256; i++) sprintf(hex + i * 2, "%02x", i);
snprintf(json, sizeof(json),
"{\"msg_id\":1,\"cmd\":\"ota_data\",\"data\":{\"data\":\"%s\"}}", hex);
n = ota_json_extract_hex(json, out, sizeof(out));
CHK(n == 512);
CHK(strcmp(out, hex) == 0);
}
/* 找不到 data 字段 */
CHK(ota_json_extract_hex("{\"cmd\":\"ota_status\"}", out, sizeof(out)) == 0);
CHK(out[0] == '\0');
printf("PASS test_json_extract_hex\n");
return 0;
}
static int test_meta_restore(void)
{
flash_init();
tx_reset();
ota_init();
CHK(_meta.state == OTA_STATE_IDLE);
/* 写一份 downloading 元数据 → 重新 init → 恢复续传状态 */
_meta.magic = OTA_MAGIC;
_meta.state = OTA_STATE_DOWNLOADING;
_meta.size = 512;
_meta.received = 256;
ota_meta_write(&_meta);
ota_init();
CHK(_meta.state == OTA_STATE_DOWNLOADING);
CHK(_meta.received == 256);
printf("PASS test_meta_restore\n");
return 0;
}
static int test_session_flow(void)
{
uint8_t img[5120];
uint32_t i;
make_image(img, sizeof(img));
flash_init();
tx_reset();
ota_init();
/* begin */
CHK(ota_cmd_begin(sizeof(img), ota_crc32(img, sizeof(img)), "1.1.0", 0) == 0);
CHK(_meta.state == OTA_STATE_DOWNLOADING);
CHK(_meta.received == 0);
/* 20 片下发 */
for (i = 0; i < sizeof(img); i += 256) {
uint32_t clen = (sizeof(img) - i > 256) ? 256 : (sizeof(img) - i);
uint8_t chunk[256];
char hex[513];
uint32_t j;
memcpy(chunk, &img[i], clen);
for (j = 0; j < clen; j++) sprintf(hex + j * 2, "%02x", chunk[j]);
CHK(ota_cmd_data(i, ota_crc32(chunk, clen), hex) == 0);
}
CHK(_meta.received == sizeof(img));
/* end → ready */
CHK(ota_cmd_end(ota_crc32(img, sizeof(img))) == 0);
CHK(_meta.state == OTA_STATE_READY);
/* 暂存区与镜像一致 (读回比对) */
{
uint8_t back[5120];
SPI_Flash_Read(back, OTA_SLOT_A_BASE, sizeof(back));
CHK(memcmp(back, img, sizeof(img)) == 0);
}
printf("PASS test_session_flow\n");
return 0;
}
static int test_begin_resume(void)
{
uint8_t img[5120];
uint32_t i;
make_image(img, sizeof(img));
flash_init();
ota_init();
/* 下载 16 片 (4096B, 触发元数据节流 flush) 后"断电重启" */
CHK(ota_cmd_begin(sizeof(img), ota_crc32(img, sizeof(img)), "", 0) == 0);
for (i = 0; i < 16 * 256; i += 256) {
uint8_t chunk[256];
char hex[513];
uint32_t j;
memcpy(chunk, &img[i], 256);
for (j = 0; j < 256; j++) sprintf(hex + j * 2, "%02x", chunk[j]);
CHK(ota_cmd_data(i, ota_crc32(chunk, 256), hex) == 0);
}
CHK(_meta.received == 16 * 256); /* 4096B, 已 flush */
/* 重启 */
ota_init();
CHK(_meta.state == OTA_STATE_DOWNLOADING);
CHK(_meta.received == 16 * 256); /* 节流点精确续传 */
/* 重新 begin → 续传点 */
CHK(ota_cmd_begin(sizeof(img), ota_crc32(img, sizeof(img)), "", 0) == 0);
CHK(_meta.received == 16 * 256);
/* 继续下载剩余 → ready */
for (i = 16 * 256; i < sizeof(img); i += 256) {
uint32_t clen = (sizeof(img) - i > 256) ? 256 : (sizeof(img) - i);
uint8_t chunk[256];
char hex[513];
uint32_t j;
memcpy(chunk, &img[i], clen);
for (j = 0; j < clen; j++) sprintf(hex + j * 2, "%02x", chunk[j]);
CHK(ota_cmd_data(i, ota_crc32(chunk, clen), hex) == 0);
}
CHK(ota_cmd_end(ota_crc32(img, sizeof(img))) == 0);
CHK(_meta.state == OTA_STATE_READY);
printf("PASS test_begin_resume\n");
return 0;
}
static int test_dup_and_gap(void)
{
uint8_t img[1024];
uint8_t chunk[256];
char hex[513];
uint32_t j;
make_image(img, sizeof(img));
flash_init();
ota_init();
CHK(ota_cmd_begin(sizeof(img), ota_crc32(img, sizeof(img)), "", 0) == 0);
memcpy(chunk, &img[0], 256);
for (j = 0; j < 256; j++) sprintf(hex + j * 2, "%02x", chunk[j]);
CHK(ota_cmd_data(0, ota_crc32(chunk, 256), hex) == 0);
/* 重复片幂等 */
CHK(ota_cmd_data(0, ota_crc32(chunk, 256), hex) == 0);
CHK(_meta.received == 256);
/* 乱序缺片 (跳 256 直接发 512) → 拒绝 */
memcpy(chunk, &img[512], 256);
for (j = 0; j < 256; j++) sprintf(hex + j * 2, "%02x", chunk[j]);
CHK(ota_cmd_data(512, ota_crc32(chunk, 256), hex) == 1);
CHK(_meta.received == 256);
/* 单片 CRC 错 → 拒绝 */
memcpy(chunk, &img[256], 256);
for (j = 0; j < 256; j++) sprintf(hex + j * 2, "%02x", chunk[j]);
CHK(ota_cmd_data(256, 0xDEADBEEFUL, hex) == 1);
CHK(_meta.received == 256);
printf("PASS test_dup_and_gap\n");
return 0;
}
/* 模拟 bootloader: 根据收到的 A7 块回 data ACK */
static void flash_step_until_ack(uint32_t *blocks_done, uint32_t total_blocks)
{
/* ota_poll 推进一帧发送 + 喂 ACK, 直到收到末块 */
uint32_t guard = 0;
while (*blocks_done < total_blocks && guard++ < 10000) {
ota_poll(); /* 发 A7 块 */
CHK(_tx_len >= 7 && _tx_buf[0] == 0x9F);
CHK(_tx_buf[4] == 0xA7);
/* 构造 data ACK: 9F SubL SubH 02 A7 00 CHECK */
{
uint8_t ack[7];
uint8_t sum = 0;
ack[0] = 0x9F; ack[1] = _tx_buf[1]; ack[2] = _tx_buf[2];
ack[3] = 0x02; ack[4] = 0xA7; ack[5] = 0x00;
sum = (uint8_t)(ack[1] + ack[2] + ack[3] + ack[4] + ack[5]);
ack[6] = sum;
ota_flash_feed_ack(ack, 7);
}
(*blocks_done)++;
}
CHK(*blocks_done == total_blocks);
}
static int test_flash_flow(void)
{
uint8_t img[5120];
make_image(img, sizeof(img));
flash_init();
tx_reset();
_ota_err_cnt = 0;
_ota_rep_cnt = 0;
_ota_rep_ok = 0xFF;
ota_init();
/* 下载 → ready */
CHK(ota_cmd_begin(sizeof(img), ota_crc32(img, sizeof(img)), "", 0) == 0);
{
uint32_t i;
for (i = 0; i < sizeof(img); i += 256) {
uint32_t clen = (sizeof(img) - i > 256) ? 256 : (sizeof(img) - i);
uint8_t chunk[256];
char hex[513];
uint32_t j;
memcpy(chunk, &img[i], clen);
for (j = 0; j < clen; j++) sprintf(hex + j * 2, "%02x", chunk[j]);
CHK(ota_cmd_data(i, ota_crc32(chunk, clen), hex) == 0);
}
}
CHK(ota_cmd_end(ota_crc32(img, sizeof(img))) == 0);
CHK(_meta.state == OTA_STATE_READY);
/* flash: 无车 → 启动 */
_mock_any_car = 0;
tx_reset();
CHK(ota_cmd_flash(0, 0) == 0);
CHK(_meta.state == OTA_STATE_FLASHING);
CHK(g_ota_flash_active == 1);
CHK(g_flag_counter_ota.flag == 1);
CHK(_ota_start_cnt >= 1); /* offlog 升级开始已写 */
/* START → 发 A5 启动帧 */
ota_poll();
CHK(_tx_len == 6 && _tx_buf[0] == 0x9F && _tx_buf[4] == 0xA5 && _tx_buf[5] == 0xA7);
/* pre_ok → ADDR */
{
uint8_t pre_ok[7] = {0x9F, 0x01, 0x00, 0x02, 0xA5, 0x00, 0xA8};
ota_flash_feed_ack(pre_ok, 7);
}
ota_poll();
/* A6 地址帧: 9F 01 00 05 A6 08 00 34 00 CHECK */
CHK(_tx_len == 10 && _tx_buf[4] == 0xA6);
CHK(_tx_buf[5] == 0x08 && _tx_buf[6] == 0x00 && _tx_buf[7] == 0x34 && _tx_buf[8] == 0x00);
/* 校验和: SubL+SubH+LEN+CMD+ADDR = 01+00+05+A6+08+00+34+00 */
CHK(_tx_buf[9] == (uint8_t)(0x01 + 0x00 + 0x05 + 0xA6 + 0x08 + 0x00 + 0x34 + 0x00));
/* addr_ok → DATA 阶段 (总块数 = ceil(5120/248) = 21) */
{
uint8_t addr_ok[7] = {0x9F, 0x01, 0x00, 0x02, 0xA6, 0x00, 0xA9};
ota_flash_feed_ack(addr_ok, 7);
}
CHK(_fs_sub == 21); /* 首块序号 = 总块数 */
/* 逐块 A7 + ACK 直到完成 */
flash_step_until_ack(&(uint32_t){0}, 21);
/* 完成后 (V1.09): 状态回 idle (镜像保留可重刷), 恢复 0x7F 模式, 结果已记,
ota_report stage=done */
CHK(_meta.state == OTA_STATE_IDLE);
CHK(_meta.last_result == 0);
CHK(g_ota_flash_active == 0);
CHK(g_flag_counter_ota.flag == 0);
CHK(_ota_result_cnt >= 1);
CHK(_last_ota_result == 0);
CHK(_ota_err_cnt == 0); /* 无失败告警 */
CHK(_ota_rep_cnt == 1); /* ota_report done 上报一次 */
CHK(_ota_rep_ok == 1);
CHK(_ota_rep_err == 0);
printf("PASS test_flash_flow (21 blocks)\n");
return 0;
}
static int test_flash_reject_car(void)
{
flash_init();
ota_init();
/* 直接构造 ready 态 */
_meta.state = OTA_STATE_READY;
_meta.size = 512;
_mock_any_car = 1;
CHK(ota_cmd_flash(0, 0) == 3); /* 有车 → 拒绝 */
CHK(g_ota_flash_active == 0);
_mock_any_car = 1;
CHK(ota_cmd_flash(0, 1) == 0); /* force → 跳过 */
CHK(g_ota_flash_active == 1);
printf("PASS test_flash_reject_car\n");
return 0;
}
static int test_flash_ready_timeout(void)
{
flash_init();
tx_reset();
_ota_err_cnt = 0;
_ota_rep_cnt = 0;
_ota_rep_ok = 0xFF;
ota_init();
_meta.state = OTA_STATE_READY;
_meta.size = 512;
_mock_any_car = 0;
CHK(ota_cmd_flash(0, 0) == 0);
ota_poll(); /* 发 A5 (首发) */
/* 不喂 ACK: 首发后 3 次超时重发 + 第 4 个超时周期 → 失败
: 1(START) A5(2) 2(START) A5(3) 3(START) A5(4) 4(fail) */
{
int k;
for (k = 0; k < 7; k++) { mock_advance(1001); ota_poll(); }
}
CHK(_meta.state == OTA_STATE_FLASH_FAILED);
CHK(_meta.last_result == OTA_ERR_READY_TIMEOUT);
CHK(_ota_err_cnt == 1); /* event_report ota_error 告警 */
CHK(_ota_err_report == OTA_ERR_READY_TIMEOUT);
CHK(g_ota_flash_active == 0);
CHK(g_flag_counter_ota.flag == 0);
CHK(_ota_result_cnt >= 1);
CHK(_last_ota_result == OTA_ERR_READY_TIMEOUT);
CHK(_ota_rep_cnt == 1); /* ota_report failed 上报一次 */
CHK(_ota_rep_ok == 0);
CHK(_ota_rep_err == OTA_ERR_READY_TIMEOUT);
printf("PASS test_flash_ready_timeout\n");
return 0;
}
/* V1.09: 刷写完成后 idle+镜像一致 → ota_begin 直接回 ready (免下载重刷) */
static int test_begin_reflash(void)
{
uint8_t img[5120];
make_image(img, sizeof(img));
flash_init();
tx_reset();
ota_init();
/* 下载 → ready */
CHK(ota_cmd_begin(sizeof(img), ota_crc32(img, sizeof(img)), "1.1.0", 0) == 0);
{
uint32_t i;
for (i = 0; i < sizeof(img); i += 256) {
uint32_t clen = (sizeof(img) - i > 256) ? 256 : (sizeof(img) - i);
uint8_t chunk[256];
char hex[513];
uint32_t j;
memcpy(chunk, &img[i], clen);
for (j = 0; j < clen; j++) sprintf(hex + j * 2, "%02x", chunk[j]);
CHK(ota_cmd_data(i, ota_crc32(chunk, clen), hex) == 0);
}
}
CHK(ota_cmd_end(ota_crc32(img, sizeof(img))) == 0);
CHK(_meta.state == OTA_STATE_READY);
/* 模拟刷写完成 → idle (镜像保留) */
_meta.state = OTA_STATE_IDLE;
CHK(_meta.size == sizeof(img));
/* 重刷: begin 同镜像 → 直接 ready, offset=size (免下载) */
CHK(ota_cmd_begin(sizeof(img), ota_crc32(img, sizeof(img)), "1.1.0", 0) == 0);
CHK(_meta.state == OTA_STATE_READY);
CHK(_meta.received == sizeof(img));
/* 不同镜像 → 新会话重下 */
CHK(ota_cmd_begin(sizeof(img) + 256, ota_crc32(img, sizeof(img)), "1.2.0", 0) == 0);
CHK(_meta.state == OTA_STATE_DOWNLOADING);
CHK(_meta.received == 0);
printf("PASS test_begin_reflash\n");
return 0;
}
int main(void)
{
int fails = 0;
fails += test_crc32();
fails += test_hex_decode();
fails += test_json_extract_hex();
fails += test_meta_restore();
fails += test_session_flow();
fails += test_begin_resume();
fails += test_dup_and_gap();
fails += test_flash_flow();
fails += test_flash_reject_car();
fails += test_flash_ready_timeout();
fails += test_begin_reflash();
if (fails == 0) {
printf("\nALL PASS\n");
return 0;
}
printf("\n%d TEST(S) FAILED\n", fails);
return 1;
}