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 置顶条目 + 待板上验证清单
This commit is contained in:
wangfq
2026-08-20 12:09:13 +08:00
parent 6c1f276002
commit 49736c341c
12 changed files with 1505 additions and 9 deletions
@@ -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>
@@ -73,7 +74,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 +114,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 +174,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 +193,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 const char *evt_names[] = {"car_enter", "car_leave", "loop_cut", "loop_restore", "ota_error"};
static char payload[512]; // static: 避免 2KB 栈溢出
int w, rem = sizeof(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 +213,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 +242,25 @@ 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_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;
@@ -677,6 +708,182 @@ 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, 先存后刷) */
char resp[400];
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(resp, sizeof(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(resp, sizeof(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, resp, strlen(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; 重复片幂等, 乱序拒收指示续传) */
char resp[400];
char hexbuf[512 + 1];
const char *hp;
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 值提取: data 对象内字段是 "data":"...", 顶层 data 对象是 "data":{ — 前者唯一 */
hp = strstr(json, "\"data\":\"");
memset(hexbuf, 0, sizeof(hexbuf));
if (hp) {
hp += 8; /* 跳过 "data":" */
strncpy(hexbuf, hp, sizeof(hexbuf) - 1);
{ char *q = strchr(hexbuf, '"'); if (q) *q = '\0'; }
}
rc = ota_cmd_data(offset, crc32, hexbuf);
if (rc == 0) {
const OtaMeta *m = ota_meta();
snprintf(resp, sizeof(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(resp, sizeof(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(resp, sizeof(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, resp, strlen(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 复核 */
char resp[300];
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(resp, sizeof(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(resp, sizeof(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(resp, sizeof(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(resp, sizeof(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, resp, strlen(resp), 1);
PRINT("IOT: ota_end rc=%d\n", rc);
} else if (strcmp(cmd_str, "ota_abort") == 0) {
/* V1.08: 中止会话, 释放暂存 */
char resp[256];
int rc = ota_cmd_abort();
snprintf(resp, sizeof(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, resp, strlen(resp), 1);
PRINT("IOT: ota_abort rc=%d\n", rc);
} else if (strcmp(cmd_str, "ota_flash") == 0) {
/* V1.08: 触发本地 ISP 刷写 (同步安全检查 → 异步执行) */
char resp[300];
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(resp, sizeof(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(resp, sizeof(resp),
"{\"msg_id\":%lu,\"cmd\":\"ota_flash\",\"ts\":%lu,\"code\":3,\"msg\":\"busy\","
"\"data\":{\"err_code\":3}}",
msg_id, dev_time_now());
} else {
snprintf(resp, sizeof(resp),
"{\"msg_id\":%lu,\"cmd\":\"ota_flash\",\"ts\":%lu,\"code\":1,\"msg\":\"param error\"}",
msg_id, dev_time_now());
}
iot_mqtt_publish(resp_topic, resp, strlen(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 状态 (含刷写进度) */
char resp[400];
const OtaMeta *m = ota_meta();
snprintf(resp, sizeof(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, resp, strlen(resp), 1);
PRINT("IOT: ota_status state=%s\n", ota_state_str((uint8_t)m->state));
} else if (strcmp(cmd_str, "ssc_net_query") == 0) {
/* SSC 网络配置查询 (只读全局, 协议 V1.02) */
char resp[400];