固件 C2:OTA 双槽 + 失败回滚(已上板实测正常 / 哈希错 / 回滚三条路径)
- 分区重排(16 MB,nvs 0x9000 不动):ota_0 / ota_1 各 3 MB、otadata 0x610000、evlog 挪到 0x612000;
flash 大小改正为 16 MB;开 CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE
- ota.{h,c}(宿主机可测):URL / sha256 校验、判定(下载中 busy、待验证拒、同镜像不重下)、健康判据
- ota_esp.{h,c}:t_ota 任务经 HTTP 下载、边下边算 sha256、核 project_name 后切启动分区;
新固件 120 s 内连不上 broker 就回滚(t_link 判 + esp_timer 兜底);重启前等回应发出、事件落盘
- 新命令 ota = 13(两条链路都收,reboot 默认 false:复位 = 五台手机断电一次);status 加 ota 一块;
新事件 LOG_EV_OTA = 18;PROJECT_VER 取 PHONERAIL_FW_VERSION
- test_ota.c 13 例,全工程 442 例全绿;版本 0.1.0-c2
- 服务器:nginx 7765 只放行 /fw/<16 hex>.bin(不过门户,路由器转发已加);tools/ota_push.py 推送;
rack protocol.py 加事件 18 / 命令 13(已部署)
- 板 B:推送到自证健康 19 s;错哈希不切分区;删掉 broker 账号后 120 s 回滚,bad 报出被回滚的版本
Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -0,0 +1,376 @@
|
||||
/* OTA 的 ESP 胶水(C2)。**只进 IDF 目标**;三条规矩(明文 HTTP + sha256、不签名、
|
||||
120 s 自证健康)与「重启 = 五路全断一次」在 ota.h。
|
||||
|
||||
---- 谁在哪个任务里跑 ------------------------------------------------------
|
||||
| 事 | 任务 |
|
||||
|---|---|
|
||||
| `ota` 命令判定、把请求交出去 | 执行器任务(命令处理器 → `ota_request()` → `start()`,只入队) |
|
||||
| HTTP 下载、算 sha256、写 flash、切启动分区 | **本文件自己的 `t_ota` 任务**,优先级与 `t_link` 同(最低一档) |
|
||||
| 健康判据、到点重启 | `t_link`(`ota_esp_poll()`) |
|
||||
| 兜底回滚 | esp_timer(app_main 卡死、`t_link` 永远起不来时也能回滚) |
|
||||
|
||||
下载要几秒到几十秒,放进执行器任务就是让按键序列等 TCP;放进 `t_link` 就是让遥测停摆。
|
||||
|
||||
---- 写 flash 对巡检的影响 ----------------------------------------------
|
||||
擦一个 4 KB 扇区要几十 ms,期间 cache 关掉、两个核上跑 flash 代码的任务都停。
|
||||
`OTA_WITH_SEQUENTIAL_WRITES` 让擦除跟着写走(一次一个扇区),而不是在 esp_ota_begin 里
|
||||
一口气擦 1 MB(那是秒级的停顿);每写一块再让出一个 tick。代价是 t_safety 偶尔晚一两拍,
|
||||
上板看 status 的 `safety.skipped` 与巡检日志里的「最迟」。
|
||||
|
||||
---- 重启前等一下 --------------------------------------------------------
|
||||
回应要先发出去(命令处理器调 reboot 回调时回应还没写),事件要先落盘(t_log 攒批最多 1 s)。
|
||||
所以重启一律是「排到 `REBOOT_DELAY_MS` 之后」,由 `t_link` 执行。 */
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_app_desc.h"
|
||||
#include "esp_http_client.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_timer.h"
|
||||
#include "evlog.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/task.h"
|
||||
#include "hal_time.h"
|
||||
#include "mbedtls/sha256.h"
|
||||
#include "netcfg.h"
|
||||
#include "ota.h"
|
||||
#include "ota_esp.h"
|
||||
#include "tasks.h"
|
||||
|
||||
static const char *TAG = "ota";
|
||||
|
||||
#define REBOOT_DELAY_MS (EVLOG_MAX_AGE_MS + 1000u) /* 回应发出去 + 事件落盘 */
|
||||
#define HTTP_TIMEOUT_MS 15000
|
||||
#define CHUNK 4096
|
||||
#define STATUS_EVERY (64u * 1024u) /* 进度多久更新一次 status(每块都更新也行,只是没必要) */
|
||||
#define BACKSTOP_EXTRA_MS 30000u /* 兜底定时器比判据晚这么久:正常路径先走 */
|
||||
|
||||
static QueueHandle_t s_q;
|
||||
static volatile bool s_boot_done;
|
||||
static volatile uint32_t s_reboot_at; /* 0 = 没排;否则 = 上电毫秒 */
|
||||
static volatile bool s_rollback; /* 排的是回滚(标记无效再重启),不是普通重启 */
|
||||
static bool s_pending; /* 只在 t_link 里读写(初始化之后) */
|
||||
static esp_timer_handle_t s_backstop;
|
||||
|
||||
static void status_edit(void (*fn)(ota_status_t *, uint32_t), uint32_t v)
|
||||
{
|
||||
ota_status_t st;
|
||||
ota_status_get(&st);
|
||||
fn(&st, v);
|
||||
ota_status_set(&st);
|
||||
}
|
||||
|
||||
static void set_got(ota_status_t *st, uint32_t v) { st->got = v; }
|
||||
|
||||
static void schedule_reboot(bool rollback)
|
||||
{
|
||||
s_rollback = rollback;
|
||||
uint32_t at = hal_time_now_ms() + REBOOT_DELAY_MS;
|
||||
s_reboot_at = (at == 0) ? 1 : at;
|
||||
}
|
||||
|
||||
/* ota.h 的 reboot 回调:已就绪的同一镜像再来一条带 reboot 的命令。执行器任务里调。 */
|
||||
static void cb_reboot(void *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
evlog_put(0, LOG_EV_OTA, OTA_EV_REBOOT, 0, 0);
|
||||
schedule_reboot(false);
|
||||
}
|
||||
|
||||
/* ota.h 的 start 回调:只入队。执行器任务里调。 */
|
||||
static hal_err_t cb_start(void *ctx, const ota_req_t *req)
|
||||
{
|
||||
(void)ctx;
|
||||
return (s_q != NULL && xQueueSend(s_q, req, 0) == pdTRUE) ? HAL_OK : HAL_ERR_STATE;
|
||||
}
|
||||
|
||||
/* 下载 + 校验 + 写 + 切分区。返回 OTA_E_NONE = 已就绪。 */
|
||||
static ota_err_t run(const ota_req_t *req)
|
||||
{
|
||||
static uint8_t buf[CHUNK]; /* 静态:只有本任务用 */
|
||||
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
const esp_partition_t *part = esp_ota_get_next_update_partition(NULL);
|
||||
if (part == NULL || part == running) {
|
||||
return OTA_E_NOSLOT;
|
||||
}
|
||||
if (req->size > part->size) {
|
||||
return OTA_E_SIZE;
|
||||
}
|
||||
/* 上一次 READY 的是另一个镜像:先把启动分区指回自己,下到一半断电也不会去启动半个镜像。 */
|
||||
const esp_partition_t *boot = esp_ota_get_boot_partition();
|
||||
if (boot != NULL && boot != running) {
|
||||
(void)esp_ota_set_boot_partition(running);
|
||||
}
|
||||
|
||||
const esp_http_client_config_t hc = {
|
||||
.url = req->url,
|
||||
.timeout_ms = HTTP_TIMEOUT_MS,
|
||||
.keep_alive_enable = true,
|
||||
};
|
||||
esp_http_client_handle_t cli = esp_http_client_init(&hc);
|
||||
if (cli == NULL) {
|
||||
return OTA_E_HTTP;
|
||||
}
|
||||
ota_err_t err = OTA_E_NONE;
|
||||
esp_ota_handle_t h = 0;
|
||||
bool begun = false;
|
||||
mbedtls_sha256_context sha;
|
||||
mbedtls_sha256_init(&sha);
|
||||
|
||||
if (esp_http_client_open(cli, 0) != ESP_OK) {
|
||||
err = OTA_E_HTTP;
|
||||
goto out;
|
||||
}
|
||||
const int64_t len = esp_http_client_fetch_headers(cli);
|
||||
const int code = esp_http_client_get_status_code(cli);
|
||||
if (code != 200) {
|
||||
ESP_LOGE(TAG, "HTTP %d", code);
|
||||
err = OTA_E_HTTP;
|
||||
goto out;
|
||||
}
|
||||
if (len > 0 && len != (int64_t)req->size) { /* 0 / -1 = 没给 Content-Length(chunked),按字节数核 */
|
||||
ESP_LOGE(TAG, "Content-Length %lld ≠ size %lu", (long long)len, (unsigned long)req->size);
|
||||
err = OTA_E_SIZE;
|
||||
goto out;
|
||||
}
|
||||
|
||||
esp_err_t e = esp_ota_begin(part, OTA_WITH_SEQUENTIAL_WRITES, &h);
|
||||
if (e != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ota_begin: %s", esp_err_to_name(e));
|
||||
/* 待验证的固件去覆盖备用槽,IDF 自己也会拒(ota.c 的 PENDING 已经先挡了一道) */
|
||||
err = (e == ESP_ERR_OTA_ROLLBACK_INVALID_STATE) ? OTA_E_NOSLOT : OTA_E_FLASH;
|
||||
goto out;
|
||||
}
|
||||
begun = true;
|
||||
mbedtls_sha256_starts(&sha, 0);
|
||||
|
||||
uint32_t got = 0;
|
||||
uint32_t shown = 0;
|
||||
while (got < req->size) {
|
||||
const uint32_t want = (req->size - got < CHUNK) ? (req->size - got) : CHUNK;
|
||||
const int n = esp_http_client_read(cli, (char *)buf, (int)want);
|
||||
if (n < 0) {
|
||||
err = OTA_E_HTTP;
|
||||
goto out;
|
||||
}
|
||||
if (n == 0) {
|
||||
break; /* 连接收了:字节数不够由下面判 */
|
||||
}
|
||||
mbedtls_sha256_update(&sha, buf, (size_t)n);
|
||||
e = esp_ota_write(h, buf, (size_t)n);
|
||||
if (e != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ota_write: %s", esp_err_to_name(e));
|
||||
err = OTA_E_FLASH;
|
||||
goto out;
|
||||
}
|
||||
got += (uint32_t)n;
|
||||
if (got - shown >= STATUS_EVERY) {
|
||||
shown = got;
|
||||
status_edit(set_got, got);
|
||||
}
|
||||
vTaskDelay(1); /* 把 cache 关掉的空档摊开,巡检不至于连着晚好几拍 */
|
||||
}
|
||||
status_edit(set_got, got);
|
||||
if (got != req->size) {
|
||||
ESP_LOGE(TAG, "只收到 %lu / %lu 字节", (unsigned long)got, (unsigned long)req->size);
|
||||
err = OTA_E_SIZE;
|
||||
goto out;
|
||||
}
|
||||
|
||||
uint8_t digest[32];
|
||||
mbedtls_sha256_finish(&sha, digest);
|
||||
if (memcmp(digest, req->sha256, sizeof(digest)) != 0) {
|
||||
char hex[65];
|
||||
ota_sha256_hex(digest, hex);
|
||||
ESP_LOGE(TAG, "sha256 对不上:算出来 %s", hex);
|
||||
err = OTA_E_HASH;
|
||||
goto out;
|
||||
}
|
||||
|
||||
begun = false; /* esp_ota_end 无论成败都释放句柄 */
|
||||
e = esp_ota_end(h);
|
||||
if (e != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ota_end: %s", esp_err_to_name(e));
|
||||
err = (e == ESP_ERR_OTA_VALIDATE_FAILED) ? OTA_E_IMAGE : OTA_E_FLASH;
|
||||
goto out;
|
||||
}
|
||||
/* 哈希对得上只说明「是服务器想发的那个文件」;再核一眼是不是本工程的固件 ——
|
||||
服务器那头传错一个 .bin(别的项目的),ESP 镜像校验照样过。 */
|
||||
esp_app_desc_t d;
|
||||
if (esp_ota_get_partition_description(part, &d) != ESP_OK ||
|
||||
strncmp(d.project_name, esp_app_get_description()->project_name, sizeof(d.project_name)) != 0) {
|
||||
ESP_LOGE(TAG, "不是本工程的固件(project_name「%.32s」)", d.project_name);
|
||||
err = OTA_E_IMAGE;
|
||||
goto out;
|
||||
}
|
||||
e = esp_ota_set_boot_partition(part);
|
||||
if (e != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ota_set_boot_partition: %s", esp_err_to_name(e));
|
||||
err = OTA_E_BOOT;
|
||||
goto out;
|
||||
}
|
||||
ESP_LOGI(TAG, "新固件 %.32s 已就绪(%s @0x%lx),%s", d.version, part->label,
|
||||
(unsigned long)part->address, req->reboot ? "马上重启" : "下次复位生效");
|
||||
|
||||
out:
|
||||
if (begun) {
|
||||
esp_ota_abort(h);
|
||||
}
|
||||
mbedtls_sha256_free(&sha);
|
||||
esp_http_client_close(cli);
|
||||
esp_http_client_cleanup(cli);
|
||||
return err;
|
||||
}
|
||||
|
||||
static void task_ota(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
ota_req_t req;
|
||||
for (;;) {
|
||||
if (xQueueReceive(s_q, &req, portMAX_DELAY) != pdTRUE) {
|
||||
continue;
|
||||
}
|
||||
ESP_LOGI(TAG, "开始下载 %s(%lu 字节)", req.url, (unsigned long)req.size);
|
||||
evlog_put(0, LOG_EV_OTA, OTA_EV_START, 0, (int32_t)req.size);
|
||||
const ota_err_t err = run(&req);
|
||||
|
||||
ota_status_t st;
|
||||
ota_status_get(&st);
|
||||
st.err = err;
|
||||
if (err == OTA_E_NONE) {
|
||||
st.phase = OTA_READY;
|
||||
ota_status_set(&st);
|
||||
evlog_put(0, LOG_EV_OTA, OTA_EV_READY, 0, (int32_t)req.size);
|
||||
if (req.reboot) {
|
||||
evlog_put(0, LOG_EV_OTA, OTA_EV_REBOOT, 0, (int32_t)req.size);
|
||||
schedule_reboot(false);
|
||||
}
|
||||
} else {
|
||||
st.phase = OTA_IDLE;
|
||||
ota_status_set(&st);
|
||||
evlog_put(0, LOG_EV_OTA, OTA_EV_FAIL, 0, (int32_t)err);
|
||||
ESP_LOGE(TAG, "OTA 失败:%s", ota_err_str(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 兜底:判据的正常路径在 t_link 里;app_main 卡死 / t_link 起不来时它来回滚。
|
||||
esp_timer 任务里调,事件来不及落盘 —— 这条路本来就只在出大事时走。 */
|
||||
static void backstop_cb(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
esp_ota_img_states_t s;
|
||||
if (esp_ota_get_state_partition(esp_ota_get_running_partition(), &s) == ESP_OK &&
|
||||
s == ESP_OTA_IMG_PENDING_VERIFY) {
|
||||
ESP_LOGE(TAG, "兜底定时器:新固件 %u s 内没自证健康,回滚",
|
||||
(unsigned)((OTA_HEALTH_MS + BACKSTOP_EXTRA_MS) / 1000u));
|
||||
esp_ota_mark_app_invalid_rollback_and_reboot();
|
||||
}
|
||||
}
|
||||
|
||||
hal_err_t ota_esp_init(void)
|
||||
{
|
||||
ota_init(NULL, NULL, NULL);
|
||||
ota_status_t st;
|
||||
ota_status_get(&st);
|
||||
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
esp_ota_img_states_t state;
|
||||
if (running != NULL && esp_ota_get_state_partition(running, &state) == ESP_OK) {
|
||||
s_pending = (state == ESP_OTA_IMG_PENDING_VERIFY);
|
||||
}
|
||||
st.pending = s_pending;
|
||||
|
||||
const esp_partition_t *bad = esp_ota_get_last_invalid_partition();
|
||||
esp_app_desc_t d;
|
||||
if (bad != NULL && esp_ota_get_partition_description(bad, &d) == ESP_OK) {
|
||||
snprintf(st.bad, sizeof(st.bad), "%.*s", (int)sizeof(st.bad) - 1, d.version);
|
||||
ESP_LOGW(TAG, "%s 里的固件 %s 被判过无效(回滚过)", bad->label, st.bad);
|
||||
}
|
||||
ota_status_set(&st);
|
||||
|
||||
const esp_partition_t *next = esp_ota_get_next_update_partition(NULL);
|
||||
ESP_LOGI(TAG, "运行在 %s @0x%lx%s;备用槽 %s", running ? running->label : "?",
|
||||
running ? (unsigned long)running->address : 0ul,
|
||||
s_pending ? "(⚠️ 待验证:" "120 s 内要连上 broker,否则回滚)" : "",
|
||||
next ? next->label : "没有(分区表不是双槽)");
|
||||
if (next != NULL && next->size != OTA_SLOT_BYTES) {
|
||||
ESP_LOGE(TAG, "备用槽 %lu 字节 ≠ OTA_SLOT_BYTES %lu:ota.h 与 partitions.csv 没对上",
|
||||
(unsigned long)next->size, (unsigned long)OTA_SLOT_BYTES);
|
||||
}
|
||||
|
||||
if (s_pending) {
|
||||
const esp_timer_create_args_t ta = { .callback = backstop_cb, .name = "ota_backstop" };
|
||||
if (esp_timer_create(&ta, &s_backstop) == ESP_OK) {
|
||||
esp_timer_start_once(s_backstop, (uint64_t)(OTA_HEALTH_MS + BACKSTOP_EXTRA_MS) * 1000u);
|
||||
}
|
||||
}
|
||||
|
||||
if (next == NULL) {
|
||||
return HAL_ERR_STATE; /* 命令回 state:ota_init 没登记回调 */
|
||||
}
|
||||
s_q = xQueueCreate(1, sizeof(ota_req_t));
|
||||
if (s_q == NULL ||
|
||||
xTaskCreate(task_ota, "t_ota", 6144, NULL, task_desc(TASK_LINK)->prio, NULL) != pdPASS) {
|
||||
return HAL_ERR_STATE;
|
||||
}
|
||||
ota_init(cb_start, cb_reboot, NULL);
|
||||
ota_status_set(&st); /* ota_init 清了状态,把 pending / bad 放回去 */
|
||||
return HAL_OK;
|
||||
}
|
||||
|
||||
void ota_esp_boot_done(void) { s_boot_done = true; }
|
||||
|
||||
void ota_esp_poll(void)
|
||||
{
|
||||
const uint32_t now = hal_time_now_ms();
|
||||
|
||||
if (s_pending) {
|
||||
netcfg_t cfg;
|
||||
net_status_t ns;
|
||||
netcfg_get(&cfg);
|
||||
net_status_get(&ns);
|
||||
const ota_health_in_t in = {
|
||||
.pending = true,
|
||||
.now_ms = now,
|
||||
.boot_done = s_boot_done,
|
||||
.wifi_set = cfg.ssid[0] != '\0',
|
||||
.wifi_up = ns.wifi == NET_UP,
|
||||
.mqtt_set = cfg.uri[0] != '\0',
|
||||
.mqtt_up = ns.mqtt == NET_UP,
|
||||
};
|
||||
const ota_health_t h = ota_health(&in);
|
||||
if (h == OTA_H_VALID) {
|
||||
const esp_err_t e = esp_ota_mark_app_valid_cancel_rollback();
|
||||
s_pending = false;
|
||||
if (s_backstop != NULL) {
|
||||
esp_timer_stop(s_backstop);
|
||||
}
|
||||
ota_status_t st;
|
||||
ota_status_get(&st);
|
||||
st.pending = false;
|
||||
ota_status_set(&st);
|
||||
evlog_put(0, LOG_EV_OTA, OTA_EV_VALID, 0, (int32_t)e);
|
||||
ESP_LOGI(TAG, "新固件 %s 自证健康(%lu ms),取消回滚:%s", esp_app_get_description()->version,
|
||||
(unsigned long)now, esp_err_to_name(e));
|
||||
} else if (h == OTA_H_ROLLBACK && s_reboot_at == 0) {
|
||||
s_pending = false; /* 不再判;下面到点回滚 */
|
||||
evlog_put(0, LOG_EV_OTA, OTA_EV_ROLLBACK, 0, 0);
|
||||
ESP_LOGE(TAG, "新固件 %u s 内没自证健康(WiFi %s / MQTT %s),回滚",
|
||||
(unsigned)(OTA_HEALTH_MS / 1000u), net_state_str(ns.wifi), net_state_str(ns.mqtt));
|
||||
schedule_reboot(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (s_reboot_at != 0 && (int32_t)(now - s_reboot_at) >= 0) {
|
||||
if (s_rollback) {
|
||||
esp_ota_mark_app_invalid_rollback_and_reboot(); /* 成功就不返回 */
|
||||
ESP_LOGE(TAG, "回滚失败(没有可回的旧版?),就地重启");
|
||||
}
|
||||
ESP_LOGW(TAG, "为换固件重启:五路全断一次,起来后按意图恢复");
|
||||
esp_restart();
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户