diff --git a/components/aes67_ota/CMakeLists.txt b/components/aes67_ota/CMakeLists.txt index bbd897e..287ae6e 100644 --- a/components/aes67_ota/CMakeLists.txt +++ b/components/aes67_ota/CMakeLists.txt @@ -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) diff --git a/components/aes67_ota/Kconfig b/components/aes67_ota/Kconfig new file mode 100644 index 0000000..bf91efe --- /dev/null +++ b/components/aes67_ota/Kconfig @@ -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 diff --git a/components/aes67_ota/aes67_ota.c b/components/aes67_ota/aes67_ota.c index 737c486..de03cb8 100644 --- a/components/aes67_ota/aes67_ota.c +++ b/components/aes67_ota/aes67_ota.c @@ -1,7 +1,274 @@ #include "aes67_ota.h" -// Stub (build step 0). -esp_err_t aes67_ota_init(void) +#include +#include + +#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; } diff --git a/components/aes67_ota/include/aes67_ota.h b/components/aes67_ota/include/aes67_ota.h index acc3450..3c134aa 100644 --- a/components/aes67_ota/include/aes67_ota.h +++ b/components/aes67_ota/include/aes67_ota.h @@ -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); diff --git a/components/aes67_web/aes67_web.c b/components/aes67_web/aes67_web.c index a6bff5d..b7be0d4 100644 --- a/components/aes67_web/aes67_web.c +++ b/components/aes67_web/aes67_web.c @@ -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; } diff --git a/components/aes67_web/include/aes67_web.h b/components/aes67_web/include/aes67_web.h index db2ac71..9f6c8f3 100644 --- a/components/aes67_web/include/aes67_web.h +++ b/components/aes67_web/include/aes67_web.h @@ -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); diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 4053113..e8612d6 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register(SRCS "main.c" "project_cfg.c" INCLUDE_DIRS "." REQUIRES esp_app_format esp_hw_support - aes67_board aes67_health aes67_net aes67_ptp + aes67_board aes67_health aes67_net aes67_ota aes67_ptp aes67_syslog aes67_tx aes67_web) diff --git a/main/main.c b/main/main.c index 5c617eb..989253b 100644 --- a/main/main.c +++ b/main/main.c @@ -1,6 +1,7 @@ #include "aes67_board.h" #include "aes67_health.h" #include "aes67_net.h" +#include "aes67_ota.h" #include "aes67_ptp.h" #include "aes67_syslog.h" #include "aes67_tx.h" @@ -33,5 +34,6 @@ void app_main(void) ESP_ERROR_CHECK(aes67_tx_init()); ESP_ERROR_CHECK(aes67_syslog_init()); project_cfg_register(); + ESP_ERROR_CHECK(aes67_ota_init()); ESP_ERROR_CHECK(aes67_web_start()); }