固件 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]>
这个提交包含在:
zhou yang
2026-09-27 14:03:13 +08:00
共同撰写人 Claude Opus 5.5
父节点 c37c08aa43
当前提交 ae1aad3b7c
共修改 29 个文件,包含 1561 行新增和 28 行删除
+5 -2
查看文件
@@ -1,5 +1,5 @@
# ESP-IDF 侧的 main 组件。
# app_main.c / ulink_esp.c / mlink_esp.c / thermal_esp.c 是 ESP 专用;其余 .c 必须同时在宿主机上编得过
# app_main.c / ulink_esp.c / mlink_esp.c / thermal_esp.c / ota_esp.c 是 ESP 专用;其余 .c 必须同时在宿主机上编得过
# (新增文件时两处都要加:这里 + host/CMakeLists.txt 的 PHONERAIL_CORE_SRCS)。
idf_component_register(
SRCS "app_main.c"
@@ -27,8 +27,11 @@ idf_component_register(
"bringup.c"
"ntc.c"
"thermal_esp.c"
"ota.c"
"ota_esp.c"
INCLUDE_DIRS "."
EMBED_TXTFILES "certs/evse_ca.pem"
REQUIRES phonerail_hal drv esp_driver_usb_serial_jtag
esp_wifi esp_netif esp_event esp_timer nvs_flash mqtt
esp_adc esp_driver_pcnt)
esp_adc esp_driver_pcnt
app_update esp_http_client mbedtls)
+10
查看文件
@@ -31,6 +31,7 @@
#include "mlink_esp.h"
#include "nvs.h"
#include "nvs_flash.h"
#include "ota_esp.h"
#include "ulink_esp.h"
static const char *TAG = "phonerail";
@@ -226,6 +227,7 @@ static void task_link(void *arg)
for (;;) {
ulink_esp_poll(100); /* 最多等 100 ms 一包;没有 USB 驱动时退化成睡 100 ms */
mlink_esp_poll();
ota_esp_poll(); /* C2:新固件的健康判据 + 到点重启 */
}
}
@@ -369,6 +371,13 @@ void app_main(void)
(int)why);
}
/* C2:OTA。紧跟在事件日志后面:「这是 OTA 后的第一次启动」「有过回滚」要尽早知道,
兜底回滚的定时器也要尽早上弦(后面任何一步卡死都要能回滚)。失败不 abort。 */
err = ota_esp_init();
if (err != HAL_OK) {
ESP_LOGE(TAG, "OTA 不可用:%s(分区表不是双槽?)", hal_err_str(err));
}
nvs_up();
s_want = want_load();
@@ -442,6 +451,7 @@ void app_main(void)
spawn(TASK_ACTUATOR, task_actuator);
spawn(TASK_LINK, task_link);
spawn(TASK_LOG, task_log);
ota_esp_boot_done(); /* 健康判据的前一半:启动序列走完了 */
ESP_LOGI(TAG, "五个任务已启动(t_safety %u ms / t_thermal %u ms),"
"I²C 预算 %u us 每周期",
+2 -2
查看文件
@@ -13,7 +13,7 @@ static const char *const k_names[CMD_COUNT] = {
[CMD_POWER_CYCLE] = "power_cycle", [CMD_KEY] = "key", [CMD_EDL] = "edl",
[CMD_VBUS] = "vbus", [CMD_FAN] = "fan", [CMD_EVENTS] = "events",
[CMD_POWER] = "power", [CMD_WIFI] = "wifi", [CMD_MQTT] = "mqtt",
[CMD_NET] = "net",
[CMD_NET] = "net", [CMD_OTA] = "ota",
};
static const char *const k_errs[CMD_ERR_COUNT] = {
@@ -105,7 +105,7 @@ static const handler_t k_handlers[CMD_COUNT] = {
[CMD_POWER_CYCLE] = do_power_cycle, [CMD_KEY] = do_key, [CMD_EDL] = do_edl,
[CMD_VBUS] = do_vbus, [CMD_FAN] = do_fan, [CMD_EVENTS] = do_events,
[CMD_POWER] = do_power, [CMD_WIFI] = do_wifi, [CMD_MQTT] = do_mqtt,
[CMD_NET] = do_net,
[CMD_NET] = do_net, [CMD_OTA] = do_ota,
};
static cmd_id_t lookup(const char *name)
+1
查看文件
@@ -58,6 +58,7 @@ typedef enum {
CMD_WIFI = 10, /* A14/C1:写 WiFi 凭据(只收 USB) */
CMD_MQTT = 11, /* A14/C1:写 broker 地址 / 账号 / 遥测周期(只收 USB) */
CMD_NET = 12, /* A14:网络状态(只读,不回显密码) */
CMD_OTA = 13, /* C2:从 URL 下载固件、校验 sha256、切启动分区(ota.h) */
CMD_COUNT,
} cmd_id_t;
+44 -1
查看文件
@@ -1,10 +1,12 @@
/* 网络的三条命令(A14 / C1,需求 IF-19):`wifi` / `mqtt` 写配置(**只收 USB**),`net` 读状态。
/* 网络的三条命令(A14 / C1,需求 IF-19):`wifi` / `mqtt` 写配置(**只收 USB**),`net` 读状态;
外加 C2 的 `ota`(下载固件,两条链路都收)。
`net` 的回应里**永远没有密码**,只有 `pass_set` / `mpass_set`(netcfg.h「密码永不外泄」)。 */
#include <string.h>
#include "cmd_priv.h"
#include "netcfg.h"
#include "ota.h"
/* 配置命令只收 USB(cmd.h 链路那一段)。宿主机测试用的 CMD_LINK_TEST 视同 USB。 */
static bool usb_only_ok(const ctx_t *c) { return c->link != CMD_LINK_WIFI; }
@@ -105,3 +107,44 @@ cmd_err_t do_net(ctx_t *c)
(unsigned long)st.tele_sent, (unsigned long)st.tele_skipped);
return CMD_OK;
}
/* `ota`(C2):`{"seq":9,"cmd":"ota","url":"http://…","sha256":"<64 位小写>","size":N,"reboot":false}`。
两条链路都收 —— 下载本身走 WiFi,但命令从 USB 来也没有「把唯一的远程通道弄断」的风险:
新固件起不来会自己回滚(ota.h)。
回 ok 时带 `act`:start = 开始下载(进度看 status 的 `ota`)/ reboot = 同一镜像已就绪,
马上重启 / ready = 同一镜像已就绪、这次不要求重启。 */
cmd_err_t do_ota(ctx_t *c)
{
const char *url = NULL;
const char *hex = NULL;
int64_t size = 0;
ota_req_t r;
memset(&r, 0, sizeof(r));
if (cj_get_str(c->o, "url", &url) != 1 || cj_get_str(c->o, "sha256", &hex) != 1 ||
cj_get_int(c->o, "size", &size) != 1 || cj_get_bool(c->o, "reboot", &r.reboot) < 0) {
return CMD_ERR_ARG;
}
if (!ota_url_ok(url) || !ota_parse_sha256(hex, r.sha256)) {
return CMD_ERR_ARG;
}
if (size <= 0 || size > (int64_t)OTA_SLOT_BYTES) {
c->bad = size;
return CMD_ERR_RANGE;
}
memcpy(r.url, url, strlen(url) + 1);
r.size = (uint32_t)size;
ota_status_t st;
ota_status_get(&st);
const ota_action_t a = ota_plan(&st, &r);
const int rc = ota_request(&r);
if (rc == OTA_ACT_BUSY) {
return CMD_ERR_BUSY;
}
if (rc != 0) {
return CMD_ERR_STATE; /* 待验证中(PENDING)/ 没有 OTA 能力 */
}
cj_w(c->w, ",\"act\":\"%s\"",
a == OTA_ACT_START ? "start" : (a == OTA_ACT_REBOOT ? "reboot" : "ready"));
return CMD_OK;
}
+3
查看文件
@@ -32,6 +32,9 @@ cmd_err_t do_wifi(ctx_t *c);
cmd_err_t do_mqtt(ctx_t *c);
cmd_err_t do_net(ctx_t *c);
/* cmd_net.c:OTA(C2,两条链路都收)。 */
cmd_err_t do_ota(ctx_t *c);
/* status 的正文(不含外层的 `{"seq":..,"ok":true` 与结尾 `}`,以逗号开头)。 */
void cmd_report_status(cj_writer_t *w, const cmd_env_t *env);
+31
查看文件
@@ -9,6 +9,7 @@
#include "cmd_priv.h"
#include "evlog.h"
#include "hal_time.h"
#include "ota.h"
#include "pca9555.h"
#include "phonerail.h"
#include "rail.h"
@@ -18,6 +19,35 @@
static const char *b(bool v) { return v ? "true" : "false"; }
/* C2:`"ota":{"st":"dl","got":N,"size":N,"err":null,"pend":false,"bad":null}`。
`err` 是上一次失败的原因(成功 / 没做过是 null);`pend` = 正在跑的固件还没自证健康;
`bad` = 被判无效、回滚掉的那份固件的版本号。版本号来自镜像头,按 ASCII 过滤一遍再写。 */
static void ota_obj(cj_writer_t *w)
{
ota_status_t st;
ota_status_get(&st);
cj_w(w, ",\"ota\":{\"st\":\"%s\",\"got\":%lu,\"size\":%lu,\"err\":", ota_phase_str(st.phase),
(unsigned long)st.got, (unsigned long)st.size);
if (st.err == OTA_E_NONE) {
cj_w(w, "null");
} else {
cj_w(w, "\"%s\"", ota_err_str(st.err));
}
cj_w(w, ",\"pend\":%s,\"bad\":", b(st.pending));
if (st.bad[0] == '\0') {
cj_w(w, "null}");
return;
}
char v[sizeof(st.bad)];
size_t n = 0;
for (const char *s = st.bad; *s != '\0' && n < sizeof(v) - 1; s++) {
const unsigned char ch = (unsigned char)*s;
v[n++] = (ch < 0x20 || ch >= 0x7F || ch == '"' || ch == '\\') ? '?' : (char)ch;
}
v[n] = '\0';
cj_w(w, "\"%s\"}", v);
}
static void rail_obj(cj_writer_t *w, int rail, const safety_snap_t *s)
{
const int i = rail - 1;
@@ -95,6 +125,7 @@ void cmd_report_status(cj_writer_t *w, const cmd_env_t *env)
(unsigned long)s->cycles, (unsigned long)s->skipped,
(unsigned long)s->i2c_errors, (unsigned long)s->events);
}
ota_obj(w);
evlog_stats_t es;
evlog_stats(&es);
cmd_stats_t cs;
+1
查看文件
@@ -572,6 +572,7 @@ const char *evlog_ev_str(log_ev_t ev)
case LOG_EV_SLOT_EMPTY: return "刀片不在位";
case LOG_EV_PD_LIMIT: return "PD限功率";
case LOG_EV_PG_TIMEOUT: return "上电等不到PG";
case LOG_EV_OTA: return "固件升级";
case LOG_EV_COUNT: break;
}
return "?";
+4
查看文件
@@ -77,6 +77,10 @@ typedef enum {
arg:bit0 = 超时那一刻 FLT# 在报;bit1 = 这一片 PCA9555 读不到,改按 INA226 电压判的。 */
LOG_EV_PG_TIMEOUT = 17,
/* C2 新增:OTA 的每一步。arg = `ota_ev_t`(ota.h);失败那一条 bus_mv = `ota_err_t`,
current_ua = 镜像字节数。整板级(rail = 0)。 */
LOG_EV_OTA = 18,
LOG_EV_COUNT,
} log_ev_t;
+189
查看文件
@@ -0,0 +1,189 @@
/* OTA 的平台无关部分(C2)。说明在 ota.h。 */
#include "ota.h"
#include <string.h>
#include "hal_lock.h"
static ota_start_fn s_start;
static ota_reboot_fn s_reboot;
static void *s_ctx;
static ota_status_t s_st;
void ota_init(ota_start_fn start, ota_reboot_fn reboot, void *ctx)
{
hal_crit_enter();
memset(&s_st, 0, sizeof(s_st));
s_start = start;
s_reboot = reboot;
s_ctx = ctx;
hal_crit_exit();
}
bool ota_url_ok(const char *url)
{
static const char k_scheme[] = "http://";
if (url == NULL || strncmp(url, k_scheme, sizeof(k_scheme) - 1) != 0) {
return false;
}
const size_t n = strlen(url);
if (n <= sizeof(k_scheme) - 1 || n > OTA_URL_MAX) {
return false;
}
for (size_t i = 0; i < n; i++) {
const unsigned char ch = (unsigned char)url[i];
if (ch <= 0x20 || ch >= 0x7F || ch == '"' || ch == '\\') {
return false;
}
}
return true;
}
static int hexval(char c)
{
if (c >= '0' && c <= '9') {
return c - '0';
}
if (c >= 'a' && c <= 'f') {
return c - 'a' + 10;
}
return -1; /* 大写也算错:同一个哈希只许一种写法(ota.h) */
}
bool ota_parse_sha256(const char *hex, uint8_t out[32])
{
if (hex == NULL || strlen(hex) != 64) {
return false;
}
uint8_t tmp[32];
for (int i = 0; i < 32; i++) {
const int hi = hexval(hex[2 * i]);
const int lo = hexval(hex[2 * i + 1]);
if (hi < 0 || lo < 0) {
return false;
}
tmp[i] = (uint8_t)((hi << 4) | lo);
}
memcpy(out, tmp, sizeof(tmp)); /* 半截错的不留在 out 里 */
return true;
}
void ota_sha256_hex(const uint8_t sha[32], char out[65])
{
static const char k[] = "0123456789abcdef";
for (int i = 0; i < 32; i++) {
out[2 * i] = k[sha[i] >> 4];
out[2 * i + 1] = k[sha[i] & 0x0F];
}
out[64] = '\0';
}
ota_action_t ota_plan(const ota_status_t *st, const ota_req_t *req)
{
if (st->phase == OTA_DL) {
return OTA_ACT_BUSY;
}
if (st->phase == OTA_READY && memcmp(st->sha256, req->sha256, sizeof(st->sha256)) == 0) {
return req->reboot ? OTA_ACT_REBOOT : OTA_ACT_NOTHING;
}
/* READY 了另一个镜像:重新下载会覆盖同一个备用槽,胶水里会先把启动分区切回当前的
(esp_ota_begin 本身就会让那个槽失效)—— 这里照常开工。 */
if (st->pending) {
return OTA_ACT_PENDING;
}
return OTA_ACT_START;
}
int ota_request(const ota_req_t *req)
{
ota_status_t st;
ota_status_get(&st);
const ota_action_t a = ota_plan(&st, req);
switch (a) {
case OTA_ACT_START:
if (s_start == NULL) {
return -1;
}
/* 先把状态置成 DL 再交任务:命令处理器下一条 status 立刻看得到「在下载」,
也挡住了紧跟着的第二条 ota(它会拿到 BUSY)。 */
st.phase = OTA_DL;
st.err = OTA_E_NONE;
st.got = 0;
st.size = req->size;
memcpy(st.sha256, req->sha256, sizeof(st.sha256));
ota_status_set(&st);
if (s_start(s_ctx, req) != HAL_OK) {
st.phase = OTA_IDLE;
ota_status_set(&st);
return -1;
}
return 0;
case OTA_ACT_REBOOT:
if (s_reboot == NULL) {
return -1;
}
s_reboot(s_ctx);
return 0;
case OTA_ACT_NOTHING:
return 0;
default:
return (int)a;
}
}
void ota_status_set(const ota_status_t *st)
{
hal_crit_enter();
s_st = *st;
hal_crit_exit();
}
void ota_status_get(ota_status_t *st)
{
hal_crit_enter();
*st = s_st;
hal_crit_exit();
}
const char *ota_phase_str(ota_phase_t p)
{
switch (p) {
case OTA_IDLE:
return "idle";
case OTA_DL:
return "dl";
case OTA_READY:
return "ready";
default:
return "?";
}
}
const char *ota_err_str(ota_err_t e)
{
static const char *const k[OTA_E_COUNT] = {
[OTA_E_NONE] = "", [OTA_E_HTTP] = "http", [OTA_E_SIZE] = "size",
[OTA_E_HASH] = "hash", [OTA_E_FLASH] = "flash", [OTA_E_IMAGE] = "image",
[OTA_E_BOOT] = "boot", [OTA_E_NOSLOT] = "noslot",
};
return ((unsigned)e < OTA_E_COUNT) ? k[e] : "?";
}
ota_health_t ota_health(const ota_health_in_t *in)
{
if (!in->pending) {
return OTA_H_NONE;
}
bool net_ok;
if (in->mqtt_set) {
net_ok = in->mqtt_up;
} else if (in->wifi_set) {
net_ok = in->wifi_up;
} else {
net_ok = true;
}
if (in->boot_done && net_ok) {
return OTA_H_VALID;
}
return (in->now_ms >= OTA_HEALTH_MS) ? OTA_H_ROLLBACK : OTA_H_WAIT;
}
+170
查看文件
@@ -0,0 +1,170 @@
/* OTA 的平台无关部分(C2,`固件开发计划.md` §7「OTA:双分区 + 失败回滚」)。
下载、写 flash、切启动分区、回滚在 ESP 胶水里(ota_esp.c);这一半宿主机可测。
---- 2026-09-27 用户定的三条 --------------------------------------------
1. **下发通道**:`ota` 命令(经 MQTT/TLS 或 USB)只带 `url` + `sha256` + `size`;
镜像本身走**明文 HTTP** 下载(服务器上一个只读目录,不过门户门禁)。
完整性全靠 sha256:它走的是认证过的通道,明文下载改不了镜像 —— 改了哈希就对不上,
**哈希不对就不切分区**(写进去的那一半留在备用槽里,无害)。
2. **不签名**:拿得到 broker 后端口令的人本来就能关五路电,签名在这里多挡不了什么;
不烧 eFuse,USB 整片烧永远可用。
3. **回滚判据**:新固件启动后 `OTA_HEALTH_MS`(120 s)之内要**自证健康** ——
启动序列走完 + 连上 broker(已发出 state)。做不到 ⇒ 标记无效、重启回旧版。
新固件在自证之前崩了 / 看门狗了,bootloader 自己回滚(CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE)。
---- ⚠️ 重启 = 五路全断一次 -------------------------------------------------
ESP 一复位,`EFUSE_EN` 靠下拉关掉(bringup.h),五台手机**硬断电**,
新固件起来后再按意图逐路恢复(§10.9)。所以 `ota` 命令的 `reboot` **默认 0**:
只下载 + 校验 + 切启动分区,**下次复位才生效**;要立刻换就显式带 `"reboot":true`。
已经下好的同一个镜像(sha256 相同)再发一次带 `"reboot":true` 的,不重下,直接重启。
回滚也是一次复位,同样五路全断一次。
---- 状态 ----------------------------------------------------------------
写者是胶水(OTA 任务 / 链路任务),读者是命令处理器(status 的 `ota` 一块),
和 netcfg 的网络状态一样整块拷贝。 */
#ifndef OTA_H
#define OTA_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "hal_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/* URL 上限 = 命令 JSON 的字符串上限(cmd_json.h 的 64)。服务器按 sha256 前 16 位起文件名,
`http://home.example:7765/fw/0123456789abcdef.bin` 才 47 个字符。 */
#define OTA_URL_MAX 64
/* 一个 app 槽的大小(partitions.csv 的 ota_0 / ota_1,各 3 MB)。size 超过它直接拒。
⚠️ 改分区表要跟着改这里 —— ESP 上 ota_esp_init() 会拿真分区大小核一遍,对不上只打错误日志。 */
#define OTA_SLOT_BYTES 0x300000u
/* 新固件自证健康的时限。WiFi 冷启动连上 + TLS 握手实测在 5~10 s 量级,
broker 的重连间隔是 10 s,120 s 够 WiFi 退避好几轮。 */
#define OTA_HEALTH_MS 120000u
typedef struct {
char url[OTA_URL_MAX + 1];
uint8_t sha256[32];
uint32_t size;
bool reboot; /* 校验通过后立刻重启(五路全断一次) */
} ota_req_t;
/* 失败原因。⚠️ 数字进事件日志(LOG_EV_OTA 失败那一条的 bus_mv),**只许追加**。 */
typedef enum {
OTA_E_NONE = 0,
OTA_E_HTTP = 1, /* 连不上 / HTTP 状态码不是 200 / 读超时 */
OTA_E_SIZE = 2, /* Content-Length 或实际字节数与 size 不符 */
OTA_E_HASH = 3, /* sha256 对不上 */
OTA_E_FLASH = 4, /* esp_ota_begin / write 失败 */
OTA_E_IMAGE = 5, /* esp_ota_end 验不过(不是 ESP 镜像 / 芯片不对 / 不是本工程) */
OTA_E_BOOT = 6, /* 切启动分区失败 */
OTA_E_NOSLOT = 7, /* 没有备用槽(分区表不是双槽)/ 新固件还没自证、不许覆盖回滚槽 */
OTA_E_COUNT,
} ota_err_t;
/* `LOG_EV_OTA` 的 arg。⚠️ 进 flash,只许追加。 */
typedef enum {
OTA_EV_START = 1, /* 开工下载 */
OTA_EV_READY = 2, /* 下完、校验过、已切启动分区 */
OTA_EV_FAIL = 3, /* 失败,bus_mv = ota_err_t */
OTA_EV_VALID = 4, /* 新固件自证健康,取消回滚 */
OTA_EV_ROLLBACK = 5, /* 新固件 OTA_HEALTH_MS 内没自证 ⇒ 标记无效、重启回旧版 */
OTA_EV_REBOOT = 6, /* 为了换固件而重启(五路全断一次) */
} ota_ev_t;
typedef enum {
OTA_IDLE = 0, /* 没做过,或上一次失败了(看 err) */
OTA_DL, /* 正在下载 + 写 */
OTA_READY, /* 已校验、已切启动分区,等复位 */
} ota_phase_t;
typedef struct {
ota_phase_t phase;
ota_err_t err; /* 上一次的失败原因;OTA_E_NONE = 没失败过 / 成功了 */
uint32_t got; /* 已下载字节 */
uint32_t size; /* 本次(或上一次)的总字节 */
uint8_t sha256[32]; /* 本次(或已就绪的那个)镜像的哈希 */
bool pending; /* 正在跑的这份固件还没自证健康(OTA 后的第一次启动) */
char bad[24]; /* 被判无效的那份固件的版本号(回滚过才有),空 = 没有 */
} ota_status_t;
/* 胶水提供:把请求交给 OTA 任务(拷贝走),立刻返回。
只在 `ota_request()` 判定要开工时被调。返回非 HAL_OK = 任务没起来。 */
typedef hal_err_t (*ota_start_fn)(void *ctx, const ota_req_t *req);
/* 胶水提供:重启(已就绪的镜像,不用重下)。 */
typedef void (*ota_reboot_fn)(void *ctx);
/* 清状态、登记两个回调。宿主机测试传假的;NULL = 没有 OTA(命令回 state)。 */
void ota_init(ota_start_fn start, ota_reboot_fn reboot, void *ctx);
/* ---- 校验(命令处理器用) ------------------------------------------------ */
/* 只收 `http://` 开头、可打印 ASCII、没有空白 / 引号 / 反斜杠,长度 ≤ OTA_URL_MAX。
https 不收:板子上没有给下载服务器的 CA(用户定的就是明文 + 哈希)。 */
bool ota_url_ok(const char *url);
/* 64 位**小写**十六进制 → 32 字节。大写、长度不对、非十六进制都是 false。
只收小写是为了让「同一个镜像」只有一种写法(READY 时按哈希判是不是同一个)。 */
bool ota_parse_sha256(const char *hex, uint8_t out[32]);
/* 32 字节 → 64 位小写十六进制 + 0(out 至少 65)。 */
void ota_sha256_hex(const uint8_t sha[32], char out[65]);
typedef enum {
OTA_ACT_START = 0, /* 开工下载 */
OTA_ACT_REBOOT, /* 同一个镜像已就绪,直接重启 */
OTA_ACT_NOTHING, /* 同一个镜像已就绪、这次也不要求重启:回 ok,什么都不做 */
OTA_ACT_BUSY, /* 正在下载 */
OTA_ACT_PENDING, /* 正在跑的固件还没自证:备用槽里是回滚用的旧版,不许覆盖 */
} ota_action_t;
/* 拿当前状态判一个(已校验过参数的)请求该怎么办。纯函数,宿主机测。 */
ota_action_t ota_plan(const ota_status_t *st, const ota_req_t *req);
/* 命令处理器入口:判 + 执行(调 start / reboot 回调)。
返回 0 = 接下了(开工 / 重启 / 已就绪),其余见 ota_action_t(BUSY / PENDING),
或 -1 = 没有 OTA 能力(没登记回调 / start 失败)。 */
int ota_request(const ota_req_t *req);
/* ---- 状态(胶水写、命令处理器读) ----------------------------------------- */
void ota_status_set(const ota_status_t *st);
void ota_status_get(ota_status_t *st);
const char *ota_phase_str(ota_phase_t p);
const char *ota_err_str(ota_err_t e);
/* ---- 健康判据(新固件第一次启动时,链路任务每圈问一次) ------------------ */
typedef enum {
OTA_H_NONE = 0, /* 不在待验证状态,什么都不用做 */
OTA_H_WAIT, /* 还没自证,也还没超时 */
OTA_H_VALID, /* 自证了 ⇒ 胶水标记有效、取消回滚 */
OTA_H_ROLLBACK, /* 超时 ⇒ 胶水标记无效、重启回旧版 */
} ota_health_t;
typedef struct {
bool pending; /* 正在跑的固件处于待验证 */
uint32_t now_ms; /* 上电起算 */
bool boot_done; /* app_main 走完了(五个任务都起来了) */
bool wifi_set; /* 配了 WiFi */
bool wifi_up;
bool mqtt_set; /* 配了 broker */
bool mqtt_up; /* 连上 broker、已发 state */
} ota_health_in_t;
/* 判据:启动走完 + 「网络该通的那一层」通了。
· 配了 broker ⇒ 要 MQTT 连上(这是 OTA 的通道,它不通这块板就再也收不到下一次 OTA);
· 只配了 WiFi ⇒ 要 WiFi 连上(下载走的是它);
· 都没配 ⇒ 只看启动走完(那块板只能经 USB 管,不会因为「没网」被误回滚)。
超时判的是 `now_ms >= OTA_HEALTH_MS`;**启动走完之前不判健康**,但超时照样算。 */
ota_health_t ota_health(const ota_health_in_t *in);
#ifdef __cplusplus
}
#endif
#endif /* OTA_H */
+376
查看文件
@@ -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();
}
}
+17
查看文件
@@ -0,0 +1,17 @@
/* OTA 的 ESP 胶水(C2)。只在 IDF 目标里有;规矩在 ota.h,实现说明在 ota_esp.c 文件头。 */
#ifndef OTA_ESP_H
#define OTA_ESP_H
#include "hal_err.h"
/* 查本次启动是不是「OTA 后待验证」、有没有被回滚掉的镜像,起 OTA 任务,登记 ota.h 的两个回调。
要在 evlog 起来之后调(会记事件)。失败不 abort:没有 OTA,别的照常。 */
hal_err_t ota_esp_init(void);
/* app_main 走完(五个任务都起来了)时调一次。健康判据的前一半。 */
void ota_esp_boot_done(void);
/* 链路任务每圈调一次:健康判据 → 标记有效 / 回滚;到点执行排好的重启。 */
void ota_esp_poll(void);
#endif /* OTA_ESP_H */
+1 -1
查看文件
@@ -3,7 +3,7 @@
#define PHONERAIL_H
/* 固件版本。规则:<主>.<次>.<修订>-<最后完成的任务包> */
#define PHONERAIL_FW_VERSION "0.1.0-a16"
#define PHONERAIL_FW_VERSION "0.1.0-c2"
/* 通道数。五路直供,见设计文档 §2。 */
#define PHONERAIL_RAIL_COUNT 5