Step 2b: firmware upload (/api/ota) with image checks and rollback self-test

- aes67_ota: GET /api/ota (version, project, build_date, idf, running,
  previous, previous_version, pending_verify, can_rollback), POST
  /api/ota streamed into the inactive slot (4 KiB chunks), POST
  /api/ota/confirm and /api/ota/rollback.
- Rejected before any flash write: bad magic, wrong chip, wrong
  project_name, chip revision outside the image's min/max range.
  esp_ota_end verifies the whole image.
- Self-test on a pending image: IP address plus HTTP 200 from our own
  /api/status within 60 s marks it valid; otherwise mark invalid and
  reboot into the previous slot. A crash before that is rolled back by
  the bootloader.
- CONFIG_AES67_OTA_SELFTEST_FORCE_FAIL (test builds only) forces a
  failed self-test.
- aes67_web: web_reboot_later() shared by /api/reboot and OTA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 23:20:04 +10:00
parent cbfc37cde6
commit 2403a6b64b
8 changed files with 301 additions and 10 deletions
+3 -1
View File
@@ -1,2 +1,4 @@
idf_component_register(SRCS "aes67_ota.c"
INCLUDE_DIRS "include")
INCLUDE_DIRS "include"
PRIV_REQUIRES aes67_web app_update bootloader_support esp_app_format
esp_http_client esp_netif esp_timer hal)
+11
View File
@@ -0,0 +1,11 @@
menu "AES67 OTA"
config AES67_OTA_SELFTEST_FORCE_FAIL
bool "Force the OTA self-test to fail (rollback test only)"
default n
help
Test builds only: the self-test always fails, so a freshly installed
image rolls back. Build with sdkconfig.selftest_fail in a separate
build directory; never enable in the normal build.
endmenu
+269 -2
View File
@@ -1,7 +1,274 @@
#include "aes67_ota.h"
// Stub (build step 0).
esp_err_t aes67_ota_init(void)
#include <stdio.h>
#include <string.h>
#include "aes67_web.h"
#include "esp_app_desc.h"
#include "esp_app_format.h"
#include "esp_http_client.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_ota_ops.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "hal/efuse_hal.h"
#define CHUNK 4096
#define SELFTEST_TIMEOUT_S 60
// Image header, first segment header, then the app description.
#define DESC_OFFSET (sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t))
#define HEAD_LEN (DESC_OFFSET + sizeof(esp_app_desc_t))
static const char *TAG = "ota";
static bool is_pending(void)
{
esp_ota_img_states_t st;
return esp_ota_get_state_partition(esp_ota_get_running_partition(), &st) == ESP_OK &&
st == ESP_OTA_IMG_PENDING_VERIFY;
}
/* ----- GET /api/ota ----- */
static esp_err_t ota_get(httpd_req_t *req)
{
const esp_app_desc_t *app = esp_app_get_description();
const esp_partition_t *run = esp_ota_get_running_partition();
const esp_partition_t *other = esp_ota_get_next_update_partition(NULL);
char built[40];
snprintf(built, sizeof(built), "%s %s", app->date, app->time);
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "version", app->version);
cJSON_AddStringToObject(o, "project", app->project_name);
cJSON_AddStringToObject(o, "build_date", built);
cJSON_AddStringToObject(o, "idf", app->idf_ver);
cJSON_AddStringToObject(o, "running", run->label);
esp_app_desc_t prev;
if (other && esp_ota_get_partition_description(other, &prev) == ESP_OK) {
cJSON_AddStringToObject(o, "previous", other->label);
cJSON_AddStringToObject(o, "previous_version", prev.version);
}
cJSON_AddBoolToObject(o, "pending_verify", is_pending());
cJSON_AddBoolToObject(o, "can_rollback", esp_ota_check_rollback_is_possible());
esp_err_t err = web_send_json(req, o);
cJSON_Delete(o);
return err;
}
/* ----- POST /api/ota ----- */
// Checks on the first bytes, before anything is written to flash.
static const char *check_head(const uint8_t *buf)
{
const esp_image_header_t *h = (const esp_image_header_t *)buf;
const esp_app_desc_t *d = (const esp_app_desc_t *)(buf + DESC_OFFSET);
static char msg[96];
if (h->magic != ESP_IMAGE_HEADER_MAGIC || d->magic_word != ESP_APP_DESC_MAGIC_WORD) {
return "not an ESP-IDF app image";
}
if (h->chip_id != CONFIG_IDF_FIRMWARE_CHIP_ID) {
return "image is for a different chip";
}
if (strncmp(d->project_name, esp_app_get_description()->project_name, sizeof(d->project_name)) != 0) {
snprintf(msg, sizeof(msg), "wrong project '%.32s'", d->project_name);
return msg;
}
unsigned rev = efuse_hal_chip_revision();
if (rev < h->min_chip_rev_full || rev > h->max_chip_rev_full) {
snprintf(msg, sizeof(msg), "image supports chip rev v%u.%u-v%u.%u, this chip is v%u.%u",
h->min_chip_rev_full / 100, h->min_chip_rev_full % 100,
h->max_chip_rev_full / 100, h->max_chip_rev_full % 100, rev / 100, rev % 100);
return msg;
}
return NULL;
}
static esp_err_t reject(httpd_req_t *req, const char *msg)
{
ESP_LOGW(TAG, "upload rejected: %s", msg);
return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, msg);
}
static esp_err_t ota_post(httpd_req_t *req)
{
const esp_partition_t *dst = esp_ota_get_next_update_partition(NULL);
if (!dst) {
return reject(req, "no OTA partition");
}
if (req->content_len < HEAD_LEN || req->content_len > dst->size) {
return reject(req, "image size out of range");
}
uint8_t *buf = malloc(CHUNK);
if (!buf) {
return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "out of memory");
}
ESP_LOGI(TAG, "upload %u bytes to %s", (unsigned)req->content_len, dst->label);
esp_ota_handle_t ota = 0;
size_t total = 0, fill = 0;
const char *err_msg = NULL;
int64_t t0 = esp_timer_get_time();
while (total < req->content_len) {
int r = httpd_req_recv(req, (char *)buf + fill, CHUNK - fill);
if (r == HTTPD_SOCK_ERR_TIMEOUT) {
continue;
}
if (r <= 0) {
err_msg = "connection lost";
break;
}
fill += r;
total += r;
if (!ota) {
// Collect the header before deciding anything.
if (fill < HEAD_LEN && total < req->content_len) {
continue;
}
if ((err_msg = check_head(buf)) != NULL) {
break;
}
const esp_app_desc_t *d = (const esp_app_desc_t *)(buf + DESC_OFFSET);
ESP_LOGI(TAG, "image %.32s %.32s", d->project_name, d->version);
if (esp_ota_begin(dst, OTA_WITH_SEQUENTIAL_WRITES, &ota) != ESP_OK) {
err_msg = "ota begin failed";
break;
}
}
if (esp_ota_write(ota, buf, fill) != ESP_OK) {
err_msg = "flash write failed";
break;
}
fill = 0;
}
free(buf);
if (err_msg) {
if (ota) {
esp_ota_abort(ota);
}
// Rejected before the whole body was read: close instead of draining it.
httpd_resp_set_hdr(req, "Connection", "close");
return reject(req, err_msg);
}
esp_err_t err = esp_ota_end(ota); // verifies the image (checksum/hash, chip)
if (err != ESP_OK) {
return reject(req, err == ESP_ERR_OTA_VALIDATE_FAILED ? "image verification failed" : "ota end failed");
}
if (esp_ota_set_boot_partition(dst) != ESP_OK) {
return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "could not set boot partition");
}
ESP_LOGW(TAG, "installed to %s in %.1f s, rebooting", dst->label,
(esp_timer_get_time() - t0) / 1e6);
httpd_resp_sendstr(req, "ok");
web_reboot_later(500);
return ESP_OK;
}
/* ----- confirm / rollback ----- */
static esp_err_t confirm_post(httpd_req_t *req)
{
if (!is_pending()) {
return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "running firmware is already confirmed");
}
esp_ota_mark_app_valid_cancel_rollback();
ESP_LOGI(TAG, "firmware confirmed via API");
return httpd_resp_sendstr(req, "");
}
static void rollback_cb(void *arg)
{
esp_ota_mark_app_invalid_rollback_and_reboot();
ESP_LOGE(TAG, "rollback failed");
}
static esp_err_t rollback_post(httpd_req_t *req)
{
if (!esp_ota_check_rollback_is_possible()) {
return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "no previous firmware to roll back to");
}
ESP_LOGW(TAG, "rollback requested via API");
httpd_resp_sendstr(req, "");
const esp_timer_create_args_t args = { .callback = rollback_cb, .name = "rollback" };
esp_timer_handle_t t;
if (esp_timer_create(&args, &t) == ESP_OK) {
esp_timer_start_once(t, 500 * 1000);
}
return ESP_OK;
}
/* ----- Self-test after an update ----- */
static bool web_answers(const esp_netif_ip_info_t *ip)
{
char url[48];
snprintf(url, sizeof(url), "http://" IPSTR "/api/status", IP2STR(&ip->ip));
esp_http_client_config_t cfg = { .url = url, .timeout_ms = 3000 };
esp_http_client_handle_t c = esp_http_client_init(&cfg);
bool ok = c && esp_http_client_perform(c) == ESP_OK && esp_http_client_get_status_code(c) == 200;
esp_http_client_cleanup(c);
return ok;
}
static void selftest_task(void *arg)
{
const char *fail = NULL;
int64_t deadline = esp_timer_get_time() + SELFTEST_TIMEOUT_S * 1000000LL;
esp_netif_t *netif = esp_netif_get_handle_from_ifkey("ETH_DEF");
bool passed = false;
while (!passed && esp_timer_get_time() < deadline) {
vTaskDelay(pdMS_TO_TICKS(1000));
esp_netif_ip_info_t ip;
if (!netif || esp_netif_get_ip_info(netif, &ip) != ESP_OK || !ip.ip.addr) {
fail = "no IP address";
continue;
}
if (!web_answers(&ip)) {
fail = "web server not answering";
continue;
}
passed = true;
}
#if CONFIG_AES67_OTA_SELFTEST_FORCE_FAIL
passed = false;
fail = "forced failure (CONFIG_AES67_OTA_SELFTEST_FORCE_FAIL)";
#endif
if (passed) {
esp_ota_mark_app_valid_cancel_rollback();
ESP_LOGI(TAG, "self-test passed, firmware confirmed");
} else if (is_pending()) { // not confirmed manually in the meantime
ESP_LOGE(TAG, "self-test failed (%s), rolling back", fail);
esp_ota_mark_app_invalid_rollback_and_reboot();
}
vTaskDelete(NULL);
}
esp_err_t aes67_ota_init(void)
{
static const httpd_uri_t uris[] = {
{ .uri = "/api/ota", .method = HTTP_GET, .handler = ota_get },
{ .uri = "/api/ota", .method = HTTP_POST, .handler = ota_post },
{ .uri = "/api/ota/confirm", .method = HTTP_POST, .handler = confirm_post },
{ .uri = "/api/ota/rollback", .method = HTTP_POST, .handler = rollback_post },
};
for (int i = 0; i < sizeof(uris) / sizeof(uris[0]); i++) {
esp_err_t err = web_register_uri(&uris[i]);
if (err != ESP_OK) {
return err;
}
}
const esp_app_desc_t *app = esp_app_get_description();
ESP_LOGI(TAG, "running %s from %s", app->version, esp_ota_get_running_partition()->label);
if (is_pending()) {
ESP_LOGW(TAG, "new firmware on trial: self-test (IP + web) within %d s", SELFTEST_TIMEOUT_S);
xTaskCreate(selftest_task, "ota_selftest", 4096, NULL, 2, NULL);
}
return ESP_OK;
}
+2
View File
@@ -4,4 +4,6 @@
#include "esp_err.h"
// Registers /api/ota routes. If the running image is pending verification, starts the
// self-test: IP address + own web server answering within 60 s, else roll back.
esp_err_t aes67_ota_init(void);
+10 -6
View File
@@ -80,16 +80,20 @@ static void reboot_cb(void *arg)
esp_restart();
}
void web_reboot_later(uint32_t ms)
{
const esp_timer_create_args_t args = { .callback = reboot_cb, .name = "reboot" };
esp_timer_handle_t t;
if (esp_timer_create(&args, &t) == ESP_OK) {
esp_timer_start_once(t, ms * 1000ULL);
}
}
static esp_err_t reboot_post(httpd_req_t *req)
{
ESP_LOGW(TAG, "reboot requested via API");
httpd_resp_sendstr(req, "");
// Let the response go out before restarting.
const esp_timer_create_args_t args = { .callback = reboot_cb, .name = "reboot" };
esp_timer_handle_t t;
if (esp_timer_create(&args, &t) == ESP_OK) {
esp_timer_start_once(t, 500 * 1000);
}
web_reboot_later(500); // let the response go out first
return ESP_OK;
}
+3
View File
@@ -16,5 +16,8 @@ esp_err_t web_register_uri(const httpd_uri_t *uri);
// Start httpd on port 80 with the core routes (/, /api/status, /api/config, /api/reboot).
esp_err_t aes67_web_start(void);
// Restart after ms (lets an HTTP response go out first).
void web_reboot_later(uint32_t ms);
// Send a cJSON object as the response body (application/json).
esp_err_t web_send_json(httpd_req_t *req, const cJSON *json);