From 5a18036ec6476f714c7c40164e91542110e3dc1d Mon Sep 17 00:00:00 2001 From: chenjintao Date: Fri, 14 Aug 2026 15:25:34 +0800 Subject: [PATCH] feat(ota): add authenticated firmware update workflow - report firmware versions at startup and every six hours with a fresh device JWT - support optional HMAC headers and retry once after authorization rejection - handle forced and confirmed updates through managed OTA state - stream images to the inactive partition and verify size, SHA-256, and format - coordinate OTA HTTPS activity with voice teardown and restoration - confirm pending images and expose configuration, status, and integration docs --- components/control_plane/CMakeLists.txt | 1 + .../control_plane/include/control_plane.h | 30 + .../internal/control_plane_internal.h | 2 + .../control_plane_multimodal_local_wake.c | 3 + .../src/runtime/control_plane_ota.c | 821 ++++++++++++++++++ .../runtime/control_plane_runtime_workers.c | 37 +- components/domain/CMakeLists.txt | 2 + components/domain/include/domain.h | 59 ++ components/domain/src/runtime/domain_ota.c | 471 ++++++++++ .../src/runtime/domain_runtime_adapters.c | 5 + components/platform/CMakeLists.txt | 3 + components/platform/include/platform.h | 18 + .../platform/src/runtime/platform_ota.c | 298 +++++++ docs/ota-firmware-integration.md | 31 + main/Kconfig.projbuild | 74 ++ sdkconfig.defaults | 12 + version.txt | 1 + 17 files changed, 1860 insertions(+), 8 deletions(-) create mode 100644 components/control_plane/src/runtime/control_plane_ota.c create mode 100644 components/domain/src/runtime/domain_ota.c create mode 100644 components/platform/src/runtime/platform_ota.c create mode 100644 docs/ota-firmware-integration.md create mode 100644 version.txt diff --git a/components/control_plane/CMakeLists.txt b/components/control_plane/CMakeLists.txt index d8e6779..c5c72c6 100644 --- a/components/control_plane/CMakeLists.txt +++ b/components/control_plane/CMakeLists.txt @@ -16,6 +16,7 @@ idf_component_register( "src/runtime/control_plane_key_input.c" "src/runtime/control_plane_remote_log_service.c" "src/runtime/control_plane_runtime_workers.c" + "src/runtime/control_plane_ota.c" "src/runtime/control_plane_factory_cli.c" INCLUDE_DIRS "include" diff --git a/components/control_plane/include/control_plane.h b/components/control_plane/include/control_plane.h index 2313c55..6f52abf 100644 --- a/components/control_plane/include/control_plane.h +++ b/components/control_plane/include/control_plane.h @@ -60,6 +60,32 @@ typedef struct { const char *parameters_json; } control_plane_multimodal_update_info_request_t; +typedef enum { + CONTROL_PLANE_OTA_STATE_DISABLED = 0, + CONTROL_PLANE_OTA_STATE_IDLE, + CONTROL_PLANE_OTA_STATE_WAITING_FOR_NETWORK, + CONTROL_PLANE_OTA_STATE_WAITING_FOR_RESOURCES, + CONTROL_PLANE_OTA_STATE_CHECKING, + CONTROL_PLANE_OTA_STATE_WAITING_FOR_CONFIRMATION, + CONTROL_PLANE_OTA_STATE_DOWNLOADING, + CONTROL_PLANE_OTA_STATE_READY_TO_REBOOT, + CONTROL_PLANE_OTA_STATE_ERROR, + CONTROL_PLANE_OTA_STATE_STOPPED, +} control_plane_ota_state_t; + +typedef struct { + control_plane_ota_state_t state; + bool running; + bool forced; + bool confirmed; + char target_version[32]; + uint64_t bytes_received; + uint64_t total_bytes; + uint32_t consecutive_failures; + int last_http_status; + esp_err_t last_error; +} control_plane_ota_snapshot_t; + esp_err_t control_plane_init(void); /** @@ -196,6 +222,10 @@ esp_err_t control_plane_multimodal_update_info( esp_err_t control_plane_start_runtime_workers(void); void control_plane_stop_runtime_workers(void); +esp_err_t control_plane_start_ota_service(void); +esp_err_t control_plane_stop_ota_service(int timeout_ms); +esp_err_t control_plane_get_ota_snapshot(control_plane_ota_snapshot_t *out); + /** * @brief Start the staged event observer runtime only. * diff --git a/components/control_plane/internal/control_plane_internal.h b/components/control_plane/internal/control_plane_internal.h index 37723ae..f1b3801 100644 --- a/components/control_plane/internal/control_plane_internal.h +++ b/components/control_plane/internal/control_plane_internal.h @@ -143,6 +143,8 @@ void control_plane_local_wake_resume_after_foreground(void); bool control_plane_local_wake_prompt_gate_active(void); void control_plane_ptt_reset_capture_window(void); bool control_plane_voice_turn_is_idle(void); +bool control_plane_ota_voice_services_allowed(void); +bool control_plane_runtime_is_stopping(void); esp_err_t control_plane_start_system_event_task(void); void control_plane_stop_system_event_task(void); diff --git a/components/control_plane/src/multimodal/control_plane_multimodal_local_wake.c b/components/control_plane/src/multimodal/control_plane_multimodal_local_wake.c index bcc7d85..babc072 100644 --- a/components/control_plane/src/multimodal/control_plane_multimodal_local_wake.c +++ b/components/control_plane/src/multimodal/control_plane_multimodal_local_wake.c @@ -390,6 +390,9 @@ esp_err_t control_plane_local_wake_start(void) #if !CONFIG_TQ_LOCAL_WAKE_ENABLE return ESP_OK; #else + if (!control_plane_ota_voice_services_allowed()) { + return ESP_ERR_INVALID_STATE; + } portENTER_CRITICAL(&s_local_wake_lock); if (s_local_wake.task != NULL || s_local_wake.started) { portEXIT_CRITICAL(&s_local_wake_lock); diff --git a/components/control_plane/src/runtime/control_plane_ota.c b/components/control_plane/src/runtime/control_plane_ota.c new file mode 100644 index 0000000..5af6f66 --- /dev/null +++ b/components/control_plane/src/runtime/control_plane_ota.c @@ -0,0 +1,821 @@ +/* + * SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "control_plane.h" + +#include +#include +#include + +#include "control_plane_internal.h" +#include "multimodal/control_plane_multimodal_device_auth.h" +#include "domain.h" +#include "esp_attr.h" +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "sdkconfig.h" +#include "soc/soc_caps.h" + +#ifndef CONFIG_TQ_OTA_ENABLE +#define CONFIG_TQ_OTA_ENABLE 0 +#endif +#ifndef CONFIG_TQ_OTA_REPORT_URL +#define CONFIG_TQ_OTA_REPORT_URL "https://gz.api.talkingq.com/ota/api/ota/versionInfoReport" +#endif +#ifndef CONFIG_TQ_OTA_INTERNAL_AUTH_SECRET +#define CONFIG_TQ_OTA_INTERNAL_AUTH_SECRET "" +#endif +#ifndef CONFIG_TQ_OTA_POLL_INTERVAL_MS +#define CONFIG_TQ_OTA_POLL_INTERVAL_MS 21600000 +#endif +#ifndef CONFIG_TQ_OTA_CONFIRMATION_POLL_MS +#define CONFIG_TQ_OTA_CONFIRMATION_POLL_MS 21600000 +#endif +#ifndef CONFIG_TQ_OTA_HTTP_TIMEOUT_MS +#define CONFIG_TQ_OTA_HTTP_TIMEOUT_MS 15000 +#endif +#ifndef CONFIG_TQ_OTA_INSTALL_TIMEOUT_MS +#define CONFIG_TQ_OTA_INSTALL_TIMEOUT_MS 600000 +#endif +#ifndef CONFIG_TQ_OTA_RETRY_BASE_MS +#define CONFIG_TQ_OTA_RETRY_BASE_MS 21600000 +#endif +#ifndef CONFIG_TQ_OTA_RETRY_MAX_MS +#define CONFIG_TQ_OTA_RETRY_MAX_MS 21600000 +#endif +#ifndef CONFIG_TQ_OTA_TASK_STACK_BYTES +#define CONFIG_TQ_OTA_TASK_STACK_BYTES 16384 +#endif +#ifndef CONFIG_TQ_OTA_MIN_FREE_INTERNAL_BYTES +#define CONFIG_TQ_OTA_MIN_FREE_INTERNAL_BYTES 32768 +#endif +#ifndef CONFIG_TQ_OTA_MIN_LARGEST_DMA_BYTES +#define CONFIG_TQ_OTA_MIN_LARGEST_DMA_BYTES 4096 +#endif +#ifndef CONFIG_TQ_NETWORK_TASK_CORE +#define CONFIG_TQ_NETWORK_TASK_CORE 0 +#endif +#ifndef CONFIG_TQ_LOCAL_WAKE_ENABLE +#define CONFIG_TQ_LOCAL_WAKE_ENABLE 0 +#endif + +#define CONTROL_PLANE_OTA_STOP_POLL_MS 20 +#define CONTROL_PLANE_OTA_SLEEP_SLICE_MS 200 +#define CONTROL_PLANE_OTA_START_GATE_MS 5000 +#define CONTROL_PLANE_OTA_INITIAL_GATE_DELAY_MS 500 +#define CONTROL_PLANE_OTA_TRANSPORT_RELEASE_WAIT_MS 100 +#define CONTROL_PLANE_OTA_TASK_STACK_WARN_BYTES 4096U +#define CONTROL_PLANE_OTA_TASK_STACK_WORDS \ + ((CONFIG_TQ_OTA_TASK_STACK_BYTES + sizeof(StackType_t) - 1U) / sizeof(StackType_t)) + +typedef struct { + bool acquired; + bool restore_multimodal; + bool restore_local_wake; +} control_plane_ota_voice_window_t; + +typedef struct { + char device_id[96]; + char serial_number[96]; + char version[DOMAIN_OTA_VERSION_MAX_LEN]; + char jwt[CONTROL_PLANE_DEVICE_AUTH_JWT_MAX_LEN]; + domain_ota_report_request_t request; +} control_plane_ota_report_context_t; + +static const char *TAG = "cp_ota"; +static portMUX_TYPE s_ota_lock = portMUX_INITIALIZER_UNLOCKED; +static TaskHandle_t s_ota_task; +static bool s_ota_stop_requested; +static bool s_ota_voice_services_reserved; +static DRAM_ATTR StaticTask_t s_ota_task_buffer; +static DRAM_ATTR StackType_t s_ota_task_stack[CONTROL_PLANE_OTA_TASK_STACK_WORDS]; +static control_plane_ota_snapshot_t s_ota_snapshot = { + .state = CONTROL_PLANE_OTA_STATE_DISABLED, + .last_http_status = -1, +}; + +static void control_plane_ota_worker(void *arg); + +static bool control_plane_ota_start_default_local_wake(void) +{ +#if CONFIG_TQ_LOCAL_WAKE_ENABLE + return control_plane_local_wake_start() == ESP_OK; +#endif + return false; +} + +static bool control_plane_ota_stop_is_requested(void) +{ + bool requested; + portENTER_CRITICAL(&s_ota_lock); + requested = s_ota_stop_requested; + portEXIT_CRITICAL(&s_ota_lock); + return requested; +} + +bool control_plane_ota_voice_services_allowed(void) +{ + bool allowed; + portENTER_CRITICAL(&s_ota_lock); + allowed = !s_ota_voice_services_reserved; + portEXIT_CRITICAL(&s_ota_lock); + return allowed; +} + +static void control_plane_ota_set_state(control_plane_ota_state_t state, + esp_err_t error, + int http_status) +{ + portENTER_CRITICAL(&s_ota_lock); + s_ota_snapshot.state = state; + s_ota_snapshot.last_error = error; + s_ota_snapshot.last_http_status = http_status; + portEXIT_CRITICAL(&s_ota_lock); +} + +static void control_plane_ota_set_candidate(const domain_ota_firmware_t *firmware) +{ + portENTER_CRITICAL(&s_ota_lock); + strlcpy(s_ota_snapshot.target_version, + firmware->version, + sizeof(s_ota_snapshot.target_version)); + s_ota_snapshot.forced = firmware->forced; + s_ota_snapshot.confirmed = firmware->confirmed; + s_ota_snapshot.bytes_received = 0U; + s_ota_snapshot.total_bytes = firmware->size_bytes; + portEXIT_CRITICAL(&s_ota_lock); +} + +static void control_plane_ota_clear_candidate(void) +{ + portENTER_CRITICAL(&s_ota_lock); + s_ota_snapshot.target_version[0] = '\0'; + s_ota_snapshot.forced = false; + s_ota_snapshot.confirmed = false; + s_ota_snapshot.bytes_received = 0U; + s_ota_snapshot.total_bytes = 0U; + portEXIT_CRITICAL(&s_ota_lock); +} + +static bool control_plane_ota_wait_interruptible(uint32_t wait_ms) +{ + uint32_t waited_ms = 0U; + while (waited_ms < wait_ms && !control_plane_ota_stop_is_requested()) { + uint32_t slice_ms = wait_ms - waited_ms; + if (slice_ms > CONTROL_PLANE_OTA_SLEEP_SLICE_MS) { + slice_ms = CONTROL_PLANE_OTA_SLEEP_SLICE_MS; + } + TickType_t ticks = pdMS_TO_TICKS(slice_ms); + vTaskDelay(ticks > 0 ? ticks : 1); + waited_ms += slice_ms; + } + return !control_plane_ota_stop_is_requested(); +} + +static uint32_t control_plane_ota_retry_delay(uint32_t failures) +{ + uint64_t delay_ms = CONFIG_TQ_OTA_RETRY_BASE_MS; + for (uint32_t i = 1U; i < failures && delay_ms < CONFIG_TQ_OTA_RETRY_MAX_MS; ++i) { + delay_ms *= 2U; + } + return delay_ms > CONFIG_TQ_OTA_RETRY_MAX_MS ? + CONFIG_TQ_OTA_RETRY_MAX_MS : (uint32_t)delay_ms; +} + +static void control_plane_ota_note_failure(esp_err_t error, int http_status) +{ + portENTER_CRITICAL(&s_ota_lock); + if (s_ota_snapshot.consecutive_failures < UINT32_MAX) { + s_ota_snapshot.consecutive_failures++; + } + s_ota_snapshot.state = CONTROL_PLANE_OTA_STATE_ERROR; + s_ota_snapshot.last_error = error; + s_ota_snapshot.last_http_status = http_status; + portEXIT_CRITICAL(&s_ota_lock); +} + +static void control_plane_ota_clear_failures(void) +{ + portENTER_CRITICAL(&s_ota_lock); + s_ota_snapshot.consecutive_failures = 0U; + s_ota_snapshot.last_error = ESP_OK; + portEXIT_CRITICAL(&s_ota_lock); +} + +static uint32_t control_plane_ota_failure_count(void) +{ + uint32_t failures; + portENTER_CRITICAL(&s_ota_lock); + failures = s_ota_snapshot.consecutive_failures; + portEXIT_CRITICAL(&s_ota_lock); + return failures; +} + +static void control_plane_ota_progress(uint64_t received, + uint64_t total, + void *user_data) +{ + (void)user_data; + uint32_t old_percent; + uint32_t new_percent = total > 0U ? (uint32_t)((received * 100U) / total) : 0U; + portENTER_CRITICAL(&s_ota_lock); + old_percent = s_ota_snapshot.total_bytes > 0U ? + (uint32_t)((s_ota_snapshot.bytes_received * 100U) / + s_ota_snapshot.total_bytes) : 0U; + s_ota_snapshot.bytes_received = received; + s_ota_snapshot.total_bytes = total; + portEXIT_CRITICAL(&s_ota_lock); + if (new_percent == 100U || new_percent / 10U != old_percent / 10U) { + ESP_LOGI(TAG, "OTA download progress: %u%%", (unsigned)new_percent); + } +} + +static void control_plane_ota_log_stack_headroom(const char *phase) +{ + UBaseType_t high_water_bytes = uxTaskGetStackHighWaterMark(NULL) * sizeof(StackType_t); + ESP_LOGI(TAG, + "OTA task stack phase=%s headroom=%uB configured=%uB", + phase != NULL ? phase : "unknown", + (unsigned)high_water_bytes, + (unsigned)CONFIG_TQ_OTA_TASK_STACK_BYTES); + if (high_water_bytes < CONTROL_PLANE_OTA_TASK_STACK_WARN_BYTES) { + ESP_LOGW(TAG, + "OTA task stack margin low: headroom=%uB required=%uB configured=%uB", + (unsigned)high_water_bytes, + (unsigned)CONTROL_PLANE_OTA_TASK_STACK_WARN_BYTES, + (unsigned)CONFIG_TQ_OTA_TASK_STACK_BYTES); + } +} + +static bool control_plane_ota_cancel(void *user_data) +{ + (void)user_data; + return control_plane_ota_stop_is_requested(); +} + +static bool control_plane_ota_runtime_is_idle(void) +{ + if (!control_plane_voice_turn_is_idle()) { + return false; + } + control_plane_multimodal_runtime_state_t state = {0}; + if (control_plane_multimodal_get_runtime_state(&state) != ESP_OK) { + return true; + } + return !state.has_active_task && !state.client_speech_active && + !state.remote_responding && + !state.local_responding_started; +} + +static void control_plane_ota_set_voice_services_reserved(bool reserved) +{ + portENTER_CRITICAL(&s_ota_lock); + s_ota_voice_services_reserved = reserved; + portEXIT_CRITICAL(&s_ota_lock); +} + +static BaseType_t control_plane_ota_create_task(TaskHandle_t *out_task) +{ + if (out_task == NULL) { + return pdFAIL; + } + *out_task = NULL; + const BaseType_t core_id = CONFIG_TQ_NETWORK_TASK_CORE < 0 ? + tskNO_AFFINITY : CONFIG_TQ_NETWORK_TASK_CORE; + const uint32_t internal_caps = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT; + ESP_LOGI(TAG, + "OTA task create: static_stack=%u internal_free=%u internal_largest=%u", + (unsigned)CONFIG_TQ_OTA_TASK_STACK_BYTES, + (unsigned)heap_caps_get_free_size(internal_caps), + (unsigned)heap_caps_get_largest_free_block(internal_caps)); + + TaskHandle_t task = xTaskCreateStaticPinnedToCore( + control_plane_ota_worker, + "cp_ota", + CONFIG_TQ_OTA_TASK_STACK_BYTES, + NULL, + 4, + s_ota_task_stack, + &s_ota_task_buffer, + core_id); + if (task == NULL) { + return pdFAIL; + } + *out_task = task; + return pdPASS; +} + +static esp_err_t control_plane_ota_acquire_voice_window( + bool initial_window, + bool allow_active_teardown, + control_plane_ota_voice_window_t *window) +{ + if (window == NULL) { + return ESP_ERR_INVALID_ARG; + } + memset(window, 0, sizeof(*window)); + + if (!initial_window && !allow_active_teardown && !control_plane_ota_runtime_is_idle()) { + return ESP_ERR_INVALID_STATE; + } + + portENTER_CRITICAL(&s_ota_lock); + if (!initial_window && s_ota_voice_services_reserved) { + portEXIT_CRITICAL(&s_ota_lock); + return ESP_ERR_INVALID_STATE; + } + s_ota_voice_services_reserved = true; + portEXIT_CRITICAL(&s_ota_lock); + + if ((!allow_active_teardown && !control_plane_ota_runtime_is_idle())) { + control_plane_ota_set_voice_services_reserved(false); + return ESP_ERR_INVALID_STATE; + } + + control_plane_multimodal_runtime_state_t multimodal_state = {0}; + bool have_multimodal_state = + control_plane_multimodal_get_runtime_state(&multimodal_state) == ESP_OK; + window->restore_multimodal = initial_window || + (have_multimodal_state && + (multimodal_state.connected || multimodal_state.starting)); + /* Every OTA HTTPS window stops the local wake worker, so restore it after + * both the initial check and later idle-time checks. */ + window->restore_local_wake = true; + window->acquired = true; + + { + esp_err_t wake_stop_err = control_plane_local_wake_stop(1000); + if (wake_stop_err != ESP_OK) { + ESP_LOGW(TAG, + "OTA voice window deferred until local wake teardown completes: %s", + esp_err_to_name(wake_stop_err)); + control_plane_ota_set_voice_services_reserved(false); + window->acquired = false; + return wake_stop_err; + } + } + if (have_multimodal_state && + (multimodal_state.connected || multimodal_state.starting || + multimodal_state.stopping || multimodal_state.has_active_task)) { + esp_err_t stop_err = control_plane_stop_multimodal_client(); + if (stop_err != ESP_OK) { + ESP_LOGW(TAG, + "OTA voice window deferred until multimodal teardown completes: %s", + esp_err_to_name(stop_err)); + control_plane_ota_set_voice_services_reserved(false); + window->acquired = false; + return stop_err; + } + } + if (!control_plane_ota_wait_interruptible( + CONTROL_PLANE_OTA_TRANSPORT_RELEASE_WAIT_MS)) { + return ESP_ERR_INVALID_STATE; + } + + domain_ota_memory_headroom_t headroom = {0}; + esp_err_t err = domain_runtime_ota_get_memory_headroom(&headroom); + if (err != ESP_OK) { + return err; + } + ESP_LOGI(TAG, + "OTA HTTPS window memory: free_internal=%u largest_internal=%u largest_dma=%u", + (unsigned)headroom.free_internal, + (unsigned)headroom.largest_internal, + (unsigned)headroom.largest_dma); + if (headroom.free_internal < CONFIG_TQ_OTA_MIN_FREE_INTERNAL_BYTES || + headroom.largest_dma < CONFIG_TQ_OTA_MIN_LARGEST_DMA_BYTES) { + ESP_LOGW(TAG, + "OTA HTTPS window deferred: need free_internal>=%u largest_dma>=%u", + (unsigned)CONFIG_TQ_OTA_MIN_FREE_INTERNAL_BYTES, + (unsigned)CONFIG_TQ_OTA_MIN_LARGEST_DMA_BYTES); + return ESP_ERR_NO_MEM; + } + return ESP_OK; +} + +static void control_plane_ota_release_voice_window(control_plane_ota_voice_window_t *window) +{ + if (window == NULL || !window->acquired) { + return; + } + control_plane_ota_set_voice_services_reserved(false); + window->acquired = false; + + ESP_LOGI(TAG, + "OTA HTTPS window released: restore_multimodal=%d restore_local_wake=%d", + window->restore_multimodal ? 1 : 0, + window->restore_local_wake ? 1 : 0); + + if (control_plane_runtime_is_stopping() || !control_plane_is_sta_connected()) { + return; + } + if (window->restore_local_wake && control_plane_voice_turn_is_idle() && + control_plane_ota_start_default_local_wake()) { + ESP_LOGI(TAG, "Local wake restore requested after OTA HTTPS window"); + } + if (window->restore_multimodal || !control_plane_voice_turn_is_idle()) { + (void)control_plane_schedule_websocket_wait_task(); + } +} + +static void control_plane_ota_restore_default_voice_services(void) +{ + if (control_plane_runtime_is_stopping() || !control_plane_is_sta_connected()) { + return; + } + if (control_plane_voice_turn_is_idle() && + control_plane_ota_start_default_local_wake()) { + ESP_LOGI(TAG, "Local wake restore requested as the default voice listener"); + } + (void)control_plane_schedule_websocket_wait_task(); +} + +static esp_err_t control_plane_ota_prepare_report(domain_ota_report_request_t *request, + char *device_id, + size_t device_id_len, + char *serial_number, + size_t serial_number_len, + char *version, + size_t version_len, + char *jwt, + size_t jwt_len, + bool force_jwt_refresh) +{ + domain_runtime_read_device_identity(device_id, + device_id_len, + serial_number, + serial_number_len); + if (device_id[0] == '\0') { + return ESP_ERR_NOT_FOUND; + } + esp_err_t err = domain_runtime_read_firmware_version(version, version_len); + if (err != ESP_OK || version[0] == '\0') { + return err != ESP_OK ? err : ESP_ERR_INVALID_STATE; + } + + control_plane_device_auth_result_t auth_result = + control_plane_multimodal_device_auth_ensure( + CONTROL_PLANE_DEVICE_AUTH_SCOPE_OTA_REPORT, + force_jwt_refresh); + if (auth_result != CONTROL_PLANE_DEVICE_AUTH_OK) { + return auth_result == CONTROL_PLANE_DEVICE_AUTH_FATAL ? + ESP_FAIL : ESP_ERR_INVALID_STATE; + } + err = control_plane_multimodal_device_auth_copy_jwt(jwt, jwt_len); + if (err != ESP_OK) { + return err; + } + + *request = (domain_ota_report_request_t) { + .report_url = CONFIG_TQ_OTA_REPORT_URL, + .device_id = device_id, + .serial_number = serial_number, + .current_version = version, + .device_jwt = jwt, + .internal_auth_secret = CONFIG_TQ_OTA_INTERNAL_AUTH_SECRET, + .timeout_ms = CONFIG_TQ_OTA_HTTP_TIMEOUT_MS, + }; + return ESP_OK; +} + +static esp_err_t control_plane_ota_check_once(domain_ota_report_response_t *response, + bool force_initial_jwt_refresh) +{ + if (response == NULL) { + return ESP_ERR_INVALID_ARG; + } + control_plane_ota_report_context_t *ctx = NULL; +#if SOC_SPIRAM_SUPPORTED + ctx = (control_plane_ota_report_context_t *)heap_caps_calloc( + 1, + sizeof(*ctx), + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); +#endif + if (ctx == NULL) { + ctx = (control_plane_ota_report_context_t *)calloc(1, sizeof(*ctx)); + } + if (ctx == NULL) { + return ESP_ERR_NO_MEM; + } + + esp_err_t err = ESP_FAIL; + for (int attempt = 0; attempt < 2; ++attempt) { + bool force_jwt_refresh = force_initial_jwt_refresh || attempt > 0; + err = control_plane_ota_prepare_report(&ctx->request, + ctx->device_id, + sizeof(ctx->device_id), + ctx->serial_number, + sizeof(ctx->serial_number), + ctx->version, + sizeof(ctx->version), + ctx->jwt, + sizeof(ctx->jwt), + force_jwt_refresh); + if (err != ESP_OK) { + break; + } + + err = domain_runtime_ota_report(&ctx->request, response); + bool unauthorized = err == ESP_OK && + (response->http_status == 401 || response->http_status == 403); + if (!unauthorized) { + break; + } + + control_plane_multimodal_device_auth_invalidate(response->http_status); + if (attempt == 0) { + ESP_LOGW(TAG, + "OTA report authorization rejected (http=%d); refreshing JWT and retrying once", + response->http_status); + } + } + memset(ctx, 0, sizeof(*ctx)); + heap_caps_free(ctx); + return err; +} + +static void control_plane_ota_worker(void *arg) +{ + (void)arg; + if (ulTaskNotifyTake(pdTRUE, + pdMS_TO_TICKS(CONTROL_PLANE_OTA_START_GATE_MS)) == 0U) { + ESP_LOGE(TAG, "OTA worker start gate timed out"); + portENTER_CRITICAL(&s_ota_lock); + s_ota_snapshot.running = false; + s_ota_snapshot.state = CONTROL_PLANE_OTA_STATE_ERROR; + s_ota_snapshot.last_error = ESP_ERR_TIMEOUT; + s_ota_voice_services_reserved = false; + s_ota_task = NULL; + portEXIT_CRITICAL(&s_ota_lock); + control_plane_ota_restore_default_voice_services(); + vTaskDelete(NULL); + return; + } + control_plane_ota_set_state(CONTROL_PLANE_OTA_STATE_IDLE, ESP_OK, -1); + bool initial_check_pending = true; + + if (!control_plane_ota_wait_interruptible(CONTROL_PLANE_OTA_INITIAL_GATE_DELAY_MS)) { + goto stopped; + } + + while (!control_plane_ota_stop_is_requested()) { + if (!control_plane_is_sta_connected()) { + control_plane_ota_set_state(CONTROL_PLANE_OTA_STATE_WAITING_FOR_NETWORK, + ESP_OK, + -1); + control_plane_ota_wait_interruptible(1000U); + continue; + } + + control_plane_ota_voice_window_t voice_window = {0}; + esp_err_t err = control_plane_ota_acquire_voice_window(initial_check_pending, + false, + &voice_window); + if (err == ESP_ERR_INVALID_STATE) { + control_plane_ota_release_voice_window(&voice_window); + control_plane_ota_set_state(CONTROL_PLANE_OTA_STATE_WAITING_FOR_RESOURCES, + ESP_OK, + -1); + control_plane_ota_wait_interruptible(1000U); + continue; + } + if (err != ESP_OK) { + initial_check_pending = false; + control_plane_ota_release_voice_window(&voice_window); + control_plane_ota_note_failure(err, -1); + control_plane_ota_wait_interruptible( + control_plane_ota_retry_delay(control_plane_ota_failure_count())); + continue; + } + + control_plane_ota_set_state(CONTROL_PLANE_OTA_STATE_CHECKING, ESP_OK, -1); + domain_ota_report_response_t *response = NULL; +#if SOC_SPIRAM_SUPPORTED + response = (domain_ota_report_response_t *)heap_caps_calloc( + 1, + sizeof(*response), + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); +#endif + if (response == NULL) { + response = (domain_ota_report_response_t *)calloc(1, sizeof(*response)); + } + if (response == NULL) { + initial_check_pending = false; + control_plane_ota_release_voice_window(&voice_window); + control_plane_ota_note_failure(ESP_ERR_NO_MEM, -1); + control_plane_ota_wait_interruptible( + control_plane_ota_retry_delay(control_plane_ota_failure_count())); + continue; + } + + err = control_plane_ota_check_once(response, initial_check_pending); + control_plane_ota_log_stack_headroom("version_report"); + if (err != ESP_OK || response->outcome == DOMAIN_OTA_REPORT_SERVER_ERROR) { + initial_check_pending = false; + control_plane_ota_release_voice_window(&voice_window); + esp_err_t report_err = err != ESP_OK ? err : ESP_ERR_INVALID_RESPONSE; + control_plane_ota_note_failure(report_err, response->http_status); + ESP_LOGW(TAG, + "OTA version report failed: err=%s http=%d business=%d", + esp_err_to_name(report_err), + response->http_status, + response->business_code); + heap_caps_free(response); + control_plane_ota_wait_interruptible( + control_plane_ota_retry_delay(control_plane_ota_failure_count())); + continue; + } + + control_plane_ota_clear_failures(); + if (response->outcome == DOMAIN_OTA_REPORT_NO_UPDATE) { + initial_check_pending = false; + control_plane_ota_release_voice_window(&voice_window); + control_plane_ota_clear_candidate(); + control_plane_ota_set_state(CONTROL_PLANE_OTA_STATE_IDLE, + ESP_OK, + response->http_status); + ESP_LOGI(TAG, + "OTA version report complete: running firmware is current (http=%d)", + response->http_status); + heap_caps_free(response); + control_plane_ota_wait_interruptible(CONFIG_TQ_OTA_POLL_INTERVAL_MS); + continue; + } + + control_plane_ota_set_candidate(&response->firmware); + if (!response->firmware.forced && !response->firmware.confirmed) { + initial_check_pending = false; + control_plane_ota_release_voice_window(&voice_window); + ESP_LOGI(TAG, + "OTA %s available; waiting for mini-program confirmation", + response->firmware.version); + control_plane_ota_set_state(CONTROL_PLANE_OTA_STATE_WAITING_FOR_CONFIRMATION, + ESP_OK, + response->http_status); + heap_caps_free(response); + control_plane_ota_wait_interruptible(CONFIG_TQ_OTA_CONFIRMATION_POLL_MS); + continue; + } + + if (!control_plane_ota_runtime_is_idle()) { + ESP_LOGI(TAG, "OTA approved; waiting for active interaction to finish"); + initial_check_pending = false; + control_plane_ota_release_voice_window(&voice_window); + heap_caps_free(response); + control_plane_ota_wait_interruptible(2000U); + continue; + } + + ESP_LOGI(TAG, + "Starting %s OTA update to %s", + response->firmware.forced ? "forced" : "confirmed", + response->firmware.version); + control_plane_ota_set_state(CONTROL_PLANE_OTA_STATE_DOWNLOADING, + ESP_OK, + response->http_status); + domain_runtime_behavior_set_keep_awake(true); + err = domain_runtime_ota_install(&response->firmware, + CONFIG_TQ_OTA_INSTALL_TIMEOUT_MS, + control_plane_ota_progress, + control_plane_ota_cancel, + NULL); + control_plane_ota_log_stack_headroom("firmware_install"); + domain_runtime_behavior_set_keep_awake(false); + if (err != ESP_OK) { + initial_check_pending = false; + control_plane_ota_release_voice_window(&voice_window); + control_plane_ota_note_failure(err, response->http_status); + ESP_LOGE(TAG, "OTA install failed: %s", esp_err_to_name(err)); + heap_caps_free(response); + if (!control_plane_ota_stop_is_requested()) { + control_plane_ota_wait_interruptible( + control_plane_ota_retry_delay(control_plane_ota_failure_count())); + } + continue; + } + + control_plane_ota_set_state(CONTROL_PLANE_OTA_STATE_READY_TO_REBOOT, + ESP_OK, + response->http_status); + ESP_LOGI(TAG, + "OTA image ready; restarting into version %s", + response->firmware.version); + heap_caps_free(response); + vTaskDelay(pdMS_TO_TICKS(1000)); + domain_runtime_restart(); + } + +stopped: + control_plane_ota_set_voice_services_reserved(false); + control_plane_ota_restore_default_voice_services(); + portENTER_CRITICAL(&s_ota_lock); + s_ota_snapshot.running = false; + s_ota_snapshot.state = CONTROL_PLANE_OTA_STATE_STOPPED; + s_ota_task = NULL; + portEXIT_CRITICAL(&s_ota_lock); + vTaskDelete(NULL); +} + +esp_err_t control_plane_start_ota_service(void) +{ +#if !CONFIG_TQ_OTA_ENABLE + control_plane_ota_set_state(CONTROL_PLANE_OTA_STATE_DISABLED, ESP_OK, -1); + return ESP_OK; +#else + esp_err_t confirm_err = domain_runtime_ota_confirm_running_image(); + if (confirm_err != ESP_OK) { + ESP_LOGE(TAG, "Unable to confirm running OTA image: %s", esp_err_to_name(confirm_err)); + return confirm_err; + } + + portENTER_CRITICAL(&s_ota_lock); + if (s_ota_task != NULL) { + portEXIT_CRITICAL(&s_ota_lock); + return ESP_OK; + } + s_ota_stop_requested = false; + /* The initial version report owns the startup network window. Voice is + * released after the first OTA result (including a bounded failure). */ + s_ota_voice_services_reserved = true; + s_ota_snapshot.running = true; + s_ota_snapshot.state = CONTROL_PLANE_OTA_STATE_IDLE; + s_ota_snapshot.last_error = ESP_OK; + portEXIT_CRITICAL(&s_ota_lock); + + TaskHandle_t task = NULL; + BaseType_t created = control_plane_ota_create_task(&task); + if (created != pdPASS) { + portENTER_CRITICAL(&s_ota_lock); + s_ota_snapshot.running = false; + s_ota_snapshot.state = CONTROL_PLANE_OTA_STATE_ERROR; + s_ota_snapshot.last_error = ESP_ERR_NO_MEM; + s_ota_voice_services_reserved = false; + portEXIT_CRITICAL(&s_ota_lock); + return ESP_ERR_NO_MEM; + } + portENTER_CRITICAL(&s_ota_lock); + s_ota_task = task; + portEXIT_CRITICAL(&s_ota_lock); + xTaskNotifyGive(task); + ESP_LOGI(TAG, "OTA service started"); + return ESP_OK; +#endif +} + +esp_err_t control_plane_stop_ota_service(int timeout_ms) +{ + if (timeout_ms < 0) { + return ESP_ERR_INVALID_ARG; + } + portENTER_CRITICAL(&s_ota_lock); + s_ota_stop_requested = true; + TaskHandle_t task = s_ota_task; + portEXIT_CRITICAL(&s_ota_lock); + if (task == NULL || timeout_ms == 0) { + return ESP_OK; + } + + TickType_t start = xTaskGetTickCount(); + TickType_t timeout_ticks = pdMS_TO_TICKS(timeout_ms); + while (true) { + portENTER_CRITICAL(&s_ota_lock); + task = s_ota_task; + portEXIT_CRITICAL(&s_ota_lock); + if (task == NULL) { + return ESP_OK; + } + if ((xTaskGetTickCount() - start) >= timeout_ticks) { + break; + } + vTaskDelay(pdMS_TO_TICKS(CONTROL_PLANE_OTA_STOP_POLL_MS)); + } + + ESP_LOGW(TAG, "OTA service stop timeout; forcing worker teardown"); + vTaskDelete(task); + portENTER_CRITICAL(&s_ota_lock); + if (s_ota_task == task) { + s_ota_task = NULL; + } + s_ota_snapshot.running = false; + s_ota_snapshot.state = CONTROL_PLANE_OTA_STATE_STOPPED; + s_ota_voice_services_reserved = false; + portEXIT_CRITICAL(&s_ota_lock); + control_plane_ota_restore_default_voice_services(); + return ESP_ERR_TIMEOUT; +} + +esp_err_t control_plane_get_ota_snapshot(control_plane_ota_snapshot_t *out) +{ + if (out == NULL) { + return ESP_ERR_INVALID_ARG; + } + portENTER_CRITICAL(&s_ota_lock); + *out = s_ota_snapshot; + portEXIT_CRITICAL(&s_ota_lock); + return ESP_OK; +} diff --git a/components/control_plane/src/runtime/control_plane_runtime_workers.c b/components/control_plane/src/runtime/control_plane_runtime_workers.c index 7479c60..4b06df5 100644 --- a/components/control_plane/src/runtime/control_plane_runtime_workers.c +++ b/components/control_plane/src/runtime/control_plane_runtime_workers.c @@ -438,7 +438,7 @@ static void control_plane_memory_monitor_sample(TickType_t now_tick, bool force) } #endif -static bool control_plane_runtime_stop_requested(void) +bool control_plane_runtime_is_stopping(void) { bool stop_requested = false; portENTER_CRITICAL(&s_runtime_lock); @@ -447,6 +447,11 @@ static bool control_plane_runtime_stop_requested(void) return stop_requested; } +static bool control_plane_runtime_stop_requested(void) +{ + return control_plane_runtime_is_stopping(); +} + static void control_plane_set_runtime_stop_requested(bool stop_requested) { portENTER_CRITICAL(&s_runtime_lock); @@ -1111,6 +1116,9 @@ static void control_plane_model_command_task(void *arg) esp_err_t control_plane_schedule_websocket_wait_task(void) { + if (!control_plane_ota_voice_services_allowed()) { + return ESP_ERR_INVALID_STATE; + } portENTER_CRITICAL(&s_runtime_lock); if (!s_runtime_stop_requested) { s_ws_start_requested = true; @@ -1394,6 +1402,9 @@ static esp_err_t control_plane_ensure_user_config_server(void) static bool control_plane_try_start_multimodal_from_state_machine(TickType_t *last_attempt_tick, bool foreground) { + if (!control_plane_ota_voice_services_allowed()) { + return false; + } control_plane_multimodal_runtime_state_t state = {0}; if (control_plane_multimodal_get_runtime_state(&state) == ESP_OK) { if (state.connected) { @@ -2266,6 +2277,11 @@ esp_err_t control_plane_start_runtime_workers(void) } } + esp_err_t ota_err = control_plane_start_ota_service(); + if (ota_err != ESP_OK) { + ESP_LOGW(TAG, "OTA service start failed: %s", esp_err_to_name(ota_err)); + } + (void)control_plane_schedule_websocket_wait_task(); #if CONFIG_TQ_SCREEN_ENABLE @@ -2318,13 +2334,17 @@ esp_err_t control_plane_start_runtime_workers(void) return key_input_err; } - esp_err_t local_wake_err = control_plane_local_wake_start(); - if (local_wake_err != ESP_OK) { - ESP_LOGE(TAG, - "Start local wake worker failed: %s", - esp_err_to_name(local_wake_err)); - control_plane_stop_runtime_workers(); - return local_wake_err; + if (control_plane_ota_voice_services_allowed()) { + esp_err_t local_wake_err = control_plane_local_wake_start(); + if (local_wake_err != ESP_OK) { + ESP_LOGE(TAG, + "Start local wake worker failed: %s", + esp_err_to_name(local_wake_err)); + control_plane_stop_runtime_workers(); + return local_wake_err; + } + } else { + ESP_LOGI(TAG, "Local wake startup deferred for OTA HTTPS window"); } esp_err_t remote_log_err = control_plane_remote_log_start(); @@ -2392,6 +2412,7 @@ void control_plane_stop_event_observer_runtime(void) void control_plane_stop_runtime_workers(void) { control_plane_set_runtime_stop_requested(true); + (void)control_plane_stop_ota_service(CONFIG_TQ_LOCAL_WAKE_STOP_WAIT_MS); (void)control_plane_stop_mini_program_provisioning( CONTROL_PLANE_TASK_STOP_WAIT_MS); control_plane_stop_user_config_server(); diff --git a/components/domain/CMakeLists.txt b/components/domain/CMakeLists.txt index 832eceb..3a800d8 100644 --- a/components/domain/CMakeLists.txt +++ b/components/domain/CMakeLists.txt @@ -8,6 +8,7 @@ idf_component_register( "src/fortune/domain_fortune_store.c" "src/fortune/domain_user_config_server.c" "src/runtime/domain_runtime_adapters.c" + "src/runtime/domain_ota.c" "src/runtime/domain_local_command.c" "src/runtime/domain_mini_program_provisioning.c" INCLUDE_DIRS @@ -19,6 +20,7 @@ idf_component_register( esp-sr log json + mbedtls EMBED_TXTFILES "assets/user_config.html" ) diff --git a/components/domain/include/domain.h b/components/domain/include/domain.h index a53fc1e..17d3c4a 100644 --- a/components/domain/include/domain.h +++ b/components/domain/include/domain.h @@ -248,6 +248,7 @@ void domain_runtime_read_device_identity(char *device_id, size_t device_id_len, char *serial_number, size_t serial_number_len); +esp_err_t domain_runtime_read_firmware_version(char *out, size_t out_len); esp_err_t domain_runtime_write_device_identity(const char *device_id, const char *serial_number); @@ -404,6 +405,64 @@ esp_err_t domain_runtime_post_json(const char *url, size_t *out_response_body_len, int *out_status); +#define DOMAIN_OTA_VERSION_MAX_LEN 32 +#define DOMAIN_OTA_URL_MAX_LEN 2048 +#define DOMAIN_OTA_SHA256_HEX_LEN 64 + +typedef enum { + DOMAIN_OTA_REPORT_NO_UPDATE = 0, + DOMAIN_OTA_REPORT_UPDATE_AVAILABLE, + DOMAIN_OTA_REPORT_SERVER_ERROR, +} domain_ota_report_outcome_t; + +typedef struct { + const char *report_url; + const char *device_id; + const char *serial_number; + const char *current_version; + const char *device_jwt; + const char *internal_auth_secret; + int timeout_ms; +} domain_ota_report_request_t; + +typedef struct { + char version[DOMAIN_OTA_VERSION_MAX_LEN]; + char url[DOMAIN_OTA_URL_MAX_LEN]; + char sha256[DOMAIN_OTA_SHA256_HEX_LEN + 1]; + uint64_t size_bytes; + bool forced; + bool confirmed; +} domain_ota_firmware_t; + +typedef struct { + domain_ota_report_outcome_t outcome; + int http_status; + int business_code; + domain_ota_firmware_t firmware; +} domain_ota_report_response_t; + +typedef struct { + uint32_t free_internal; + uint32_t largest_internal; + uint32_t largest_dma; +} domain_ota_memory_headroom_t; + +typedef void (*domain_ota_progress_callback_t)(uint64_t bytes_received, + uint64_t total_bytes, + void *user_data); +typedef bool (*domain_ota_cancel_callback_t)(void *user_data); + +esp_err_t domain_runtime_ota_report(const domain_ota_report_request_t *request, + domain_ota_report_response_t *response); +esp_err_t domain_runtime_ota_install(const domain_ota_firmware_t *firmware, + int timeout_ms, + domain_ota_progress_callback_t progress_cb, + domain_ota_cancel_callback_t cancel_cb, + void *user_data); +esp_err_t domain_runtime_ota_confirm_running_image(void); +esp_err_t domain_runtime_ota_get_memory_headroom(domain_ota_memory_headroom_t *out); +void domain_runtime_restart(void); + /** * @brief Console (UART) line-transport primitives, bridged to the platform. * diff --git a/components/domain/src/runtime/domain_ota.c b/components/domain/src/runtime/domain_ota.c new file mode 100644 index 0000000..d8527e9 --- /dev/null +++ b/components/domain/src/runtime/domain_ota.c @@ -0,0 +1,471 @@ +/* + * SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "domain.h" + +#include +#include +#include +#include + +#include "cJSON.h" +#include "esp_log.h" +#include "esp_random.h" +#include "mbedtls/base64.h" +#include "mbedtls/md.h" +#include "mbedtls/sha256.h" +#include "platform.h" + +#define DOMAIN_OTA_RESPONSE_CAPACITY 4096 +#define DOMAIN_OTA_NONCE_LEN 36 +#define DOMAIN_OTA_AUTH_VALUE_MAX_LEN 128 +#define DOMAIN_OTA_MIN_VALID_EPOCH 1700000000LL + +static const char *TAG = "domain_ota"; + +typedef struct { + domain_ota_progress_callback_t progress_cb; + domain_ota_cancel_callback_t cancel_cb; + void *user_data; +} domain_ota_install_bridge_t; + +static esp_err_t domain_ota_copy_json_string(cJSON *object, + const char *name, + char *out, + size_t out_len) +{ + cJSON *item = cJSON_GetObjectItemCaseSensitive(object, name); + if (!cJSON_IsString(item) || item->valuestring == NULL || + strlcpy(out, item->valuestring, out_len) >= out_len) { + return ESP_ERR_INVALID_RESPONSE; + } + return ESP_OK; +} + +static esp_err_t domain_ota_base64(const uint8_t *input, + size_t input_len, + char *out, + size_t out_len) +{ + size_t encoded_len = 0U; + int ret = mbedtls_base64_encode((uint8_t *)out, + out_len, + &encoded_len, + input, + input_len); + if (ret != 0 || encoded_len >= out_len) { + return ESP_FAIL; + } + out[encoded_len] = '\0'; + return ESP_OK; +} + +static esp_err_t domain_ota_parse_url_target(const char *url, + char *path, + size_t path_len, + char *query, + size_t query_len) +{ + const char *authority = strstr(url, "://"); + if (authority == NULL) { + return ESP_ERR_INVALID_ARG; + } + const char *target = strchr(authority + 3, '/'); + if (target == NULL) { + target = "/"; + } + + const char *query_begin = strchr(target, '?'); + size_t target_len = query_begin == NULL ? strlen(target) : + (size_t)(query_begin - target); + if (target_len == 0U || target_len >= path_len) { + return ESP_ERR_INVALID_SIZE; + } + memcpy(path, target, target_len); + path[target_len] = '\0'; + + query[0] = '\0'; + if (query_begin != NULL && strlcpy(query, query_begin + 1, query_len) >= query_len) { + return ESP_ERR_INVALID_SIZE; + } + return ESP_OK; +} + +static void domain_ota_generate_nonce(char out[DOMAIN_OTA_NONCE_LEN + 1]) +{ + uint8_t random_bytes[16]; + esp_fill_random(random_bytes, sizeof(random_bytes)); + snprintf(out, + DOMAIN_OTA_NONCE_LEN + 1, + "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", + random_bytes[0], random_bytes[1], random_bytes[2], random_bytes[3], + random_bytes[4], random_bytes[5], random_bytes[6], random_bytes[7], + random_bytes[8], random_bytes[9], random_bytes[10], random_bytes[11], + random_bytes[12], random_bytes[13], random_bytes[14], random_bytes[15]); +} + +static esp_err_t domain_ota_build_internal_auth(const char *url, + const char *payload, + const char *secret, + char *timestamp, + size_t timestamp_len, + char *nonce, + size_t nonce_len, + char *signature, + size_t signature_len) +{ + time_t now = time(NULL); + if ((int64_t)now < DOMAIN_OTA_MIN_VALID_EPOCH) { + return ESP_ERR_INVALID_STATE; + } + if (snprintf(timestamp, timestamp_len, "%lld", (long long)now) >= (int)timestamp_len) { + return ESP_ERR_INVALID_SIZE; + } + if (nonce_len < DOMAIN_OTA_NONCE_LEN + 1U) { + return ESP_ERR_INVALID_SIZE; + } + domain_ota_generate_nonce(nonce); + + uint8_t body_digest[32]; + if (mbedtls_sha256((const uint8_t *)payload, + strlen(payload), + body_digest, + 0) != 0) { + return ESP_FAIL; + } + char body_hash[DOMAIN_OTA_AUTH_VALUE_MAX_LEN]; + esp_err_t err = domain_ota_base64(body_digest, + sizeof(body_digest), + body_hash, + sizeof(body_hash)); + if (err != ESP_OK) { + return err; + } + + char path[256]; + char query[256]; + err = domain_ota_parse_url_target(url, path, sizeof(path), query, sizeof(query)); + if (err != ESP_OK) { + return err; + } + + int payload_len = snprintf(NULL, + 0, + "POST\n%s\n%s\n%s\n%s\n%s", + path, + query, + body_hash, + timestamp, + nonce); + if (payload_len <= 0) { + return ESP_FAIL; + } + char *signature_payload = (char *)malloc((size_t)payload_len + 1U); + if (signature_payload == NULL) { + return ESP_ERR_NO_MEM; + } + snprintf(signature_payload, + (size_t)payload_len + 1U, + "POST\n%s\n%s\n%s\n%s\n%s", + path, + query, + body_hash, + timestamp, + nonce); + + uint8_t digest[32]; + const mbedtls_md_info_t *md_info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); + int hmac_ret = md_info == NULL ? -1 : + mbedtls_md_hmac(md_info, + (const uint8_t *)secret, + strlen(secret), + (const uint8_t *)signature_payload, + (size_t)payload_len, + digest); + free(signature_payload); + if (hmac_ret != 0) { + return ESP_FAIL; + } + return domain_ota_base64(digest, sizeof(digest), signature, signature_len); +} + +static esp_err_t domain_ota_build_report_payload(const domain_ota_report_request_t *request, + char **out_payload) +{ + cJSON *root = cJSON_CreateObject(); + if (root == NULL) { + return ESP_ERR_NO_MEM; + } + bool ok = cJSON_AddStringToObject(root, "deviceId", request->device_id) != NULL && + cJSON_AddStringToObject(root, "version", request->current_version) != NULL; + if (ok && request->serial_number != NULL && request->serial_number[0] != '\0') { + ok = cJSON_AddStringToObject(root, "serialNumber", request->serial_number) != NULL; + } + char *payload = ok ? cJSON_PrintUnformatted(root) : NULL; + cJSON_Delete(root); + if (payload == NULL) { + return ESP_ERR_NO_MEM; + } + *out_payload = payload; + return ESP_OK; +} + +static esp_err_t domain_ota_parse_report_response(const char *body, + domain_ota_report_response_t *response) +{ + cJSON *root = cJSON_Parse(body); + if (root == NULL) { + return ESP_ERR_INVALID_RESPONSE; + } + + cJSON *code = cJSON_GetObjectItemCaseSensitive(root, "code"); + response->business_code = cJSON_IsNumber(code) ? code->valueint : 0; + cJSON *data = cJSON_GetObjectItemCaseSensitive(root, "data"); + cJSON *not_uptodate = cJSON_IsObject(data) ? + cJSON_GetObjectItemCaseSensitive(data, "notUptodate") : NULL; + if (response->business_code != 200 || !cJSON_IsNumber(not_uptodate) || + not_uptodate->valueint != 1) { + response->outcome = DOMAIN_OTA_REPORT_SERVER_ERROR; + cJSON_Delete(root); + return ESP_OK; + } + + domain_ota_firmware_t *firmware = &response->firmware; + esp_err_t err = domain_ota_copy_json_string(data, + "version", + firmware->version, + sizeof(firmware->version)); + if (err == ESP_OK) { + err = domain_ota_copy_json_string(data, + "url", + firmware->url, + sizeof(firmware->url)); + } + if (err == ESP_OK) { + err = domain_ota_copy_json_string(data, + "sha256", + firmware->sha256, + sizeof(firmware->sha256)); + } + + cJSON *size_bytes = cJSON_GetObjectItemCaseSensitive(data, "sizeBytes"); + cJSON *forced = cJSON_GetObjectItemCaseSensitive(data, "force"); + cJSON *confirmed = cJSON_GetObjectItemCaseSensitive(data, "update"); + if (err != ESP_OK || !cJSON_IsNumber(size_bytes) || size_bytes->valuedouble <= 0 || + size_bytes->valuedouble > (double)UINT32_MAX || + (double)(uint64_t)size_bytes->valuedouble != size_bytes->valuedouble || + strlen(firmware->sha256) != 64U) { + cJSON_Delete(root); + return ESP_ERR_INVALID_RESPONSE; + } + firmware->size_bytes = (uint64_t)size_bytes->valuedouble; + firmware->forced = cJSON_IsNumber(forced) && forced->valueint == 1; + firmware->confirmed = cJSON_IsNumber(confirmed) && confirmed->valueint == 1; + response->outcome = DOMAIN_OTA_REPORT_UPDATE_AVAILABLE; + cJSON_Delete(root); + return ESP_OK; +} + +esp_err_t domain_runtime_ota_report(const domain_ota_report_request_t *request, + domain_ota_report_response_t *response) +{ + if (response != NULL) { + memset(response, 0, sizeof(*response)); + response->http_status = -1; + } + if (request == NULL || response == NULL || request->report_url == NULL || + strncmp(request->report_url, "https://", 8) != 0 || + request->device_id == NULL || request->device_id[0] == '\0' || + request->current_version == NULL || request->current_version[0] == '\0' || + request->device_jwt == NULL || request->device_jwt[0] == '\0' || + request->timeout_ms <= 0) { + return ESP_ERR_INVALID_ARG; + } + + char *payload = NULL; + esp_err_t err = domain_ota_build_report_payload(request, &payload); + if (err != ESP_OK) { + return err; + } + + char bearer[1600]; + if (snprintf(bearer, sizeof(bearer), "Bearer %s", request->device_jwt) >= + (int)sizeof(bearer)) { + cJSON_free(payload); + return ESP_ERR_INVALID_SIZE; + } + + platform_https_header_t headers[4] = { + {.name = "Authorization", .value = bearer}, + }; + size_t header_count = 1U; + char timestamp[32] = {0}; + char nonce[DOMAIN_OTA_NONCE_LEN + 1] = {0}; + char signature[DOMAIN_OTA_AUTH_VALUE_MAX_LEN] = {0}; + if (request->internal_auth_secret != NULL && request->internal_auth_secret[0] != '\0') { + err = domain_ota_build_internal_auth(request->report_url, + payload, + request->internal_auth_secret, + timestamp, + sizeof(timestamp), + nonce, + sizeof(nonce), + signature, + sizeof(signature)); + if (err != ESP_OK) { + cJSON_free(payload); + return err; + } + headers[header_count++] = (platform_https_header_t) { + .name = "X-Internal-Timestamp", .value = timestamp, + }; + headers[header_count++] = (platform_https_header_t) { + .name = "X-Internal-Nonce", .value = nonce, + }; + headers[header_count++] = (platform_https_header_t) { + .name = "X-Internal-Signature", .value = signature, + }; + } + + char *body = (char *)calloc(1, DOMAIN_OTA_RESPONSE_CAPACITY); + if (body == NULL) { + cJSON_free(payload); + return ESP_ERR_NO_MEM; + } + platform_https_post_json_request_t platform_request = { + .url = request->report_url, + .payload = payload, + .headers = headers, + .header_count = header_count, + .timeout_ms = request->timeout_ms, + .tx_buffer_size = 2048, + .rx_buffer_size = 2048, + }; + platform_https_response_t platform_response = { + .body = body, + .body_capacity = DOMAIN_OTA_RESPONSE_CAPACITY, + }; + err = platform_https_post_json(&platform_request, &platform_response); + cJSON_free(payload); + if (err != ESP_OK) { + free(body); + return err; + } + + response->http_status = platform_response.status_code; + ESP_LOGI(TAG, + "OTA version report response: http=%d body_len=%u body=%.*s", + platform_response.status_code, + (unsigned)platform_response.body_len, + (int)platform_response.body_len, + body); + if (platform_response.status_code == 204) { + response->outcome = DOMAIN_OTA_REPORT_NO_UPDATE; + free(body); + return ESP_OK; + } + if (platform_response.status_code != 200) { + response->outcome = DOMAIN_OTA_REPORT_SERVER_ERROR; + free(body); + return ESP_OK; + } + err = domain_ota_parse_report_response(body, response); + free(body); + return err; +} + +static int domain_ota_hex_value(char value) +{ + if (value >= '0' && value <= '9') { + return value - '0'; + } + if (value >= 'a' && value <= 'f') { + return value - 'a' + 10; + } + if (value >= 'A' && value <= 'F') { + return value - 'A' + 10; + } + return -1; +} + +static esp_err_t domain_ota_parse_sha256(const char *hex, uint8_t out[32]) +{ + if (hex == NULL || strlen(hex) != 64U) { + return ESP_ERR_INVALID_ARG; + } + for (size_t i = 0; i < 32U; ++i) { + int high = domain_ota_hex_value(hex[i * 2U]); + int low = domain_ota_hex_value(hex[i * 2U + 1U]); + if (high < 0 || low < 0) { + return ESP_ERR_INVALID_ARG; + } + out[i] = (uint8_t)((high << 4) | low); + } + return ESP_OK; +} + +static void domain_ota_progress_bridge(uint64_t received, uint64_t total, void *user_data) +{ + domain_ota_install_bridge_t *bridge = (domain_ota_install_bridge_t *)user_data; + if (bridge->progress_cb != NULL) { + bridge->progress_cb(received, total, bridge->user_data); + } +} + +static bool domain_ota_cancel_bridge(void *user_data) +{ + domain_ota_install_bridge_t *bridge = (domain_ota_install_bridge_t *)user_data; + return bridge->cancel_cb != NULL && bridge->cancel_cb(bridge->user_data); +} + +esp_err_t domain_runtime_ota_install(const domain_ota_firmware_t *firmware, + int timeout_ms, + domain_ota_progress_callback_t progress_cb, + domain_ota_cancel_callback_t cancel_cb, + void *user_data) +{ + if (firmware == NULL || strncmp(firmware->url, "https://", 8) != 0 || + firmware->size_bytes == 0U || timeout_ms <= 0) { + return ESP_ERR_INVALID_ARG; + } + uint8_t expected_sha256[32]; + esp_err_t err = domain_ota_parse_sha256(firmware->sha256, expected_sha256); + if (err != ESP_OK) { + return err; + } + domain_ota_install_bridge_t bridge = { + .progress_cb = progress_cb, + .cancel_cb = cancel_cb, + .user_data = user_data, + }; + return platform_ota_install_image(firmware->url, + expected_sha256, + firmware->size_bytes, + timeout_ms, + domain_ota_progress_bridge, + domain_ota_cancel_bridge, + &bridge); +} + +esp_err_t domain_runtime_ota_confirm_running_image(void) +{ + return platform_ota_confirm_running_image(); +} + +esp_err_t domain_runtime_ota_get_memory_headroom(domain_ota_memory_headroom_t *out) +{ + if (out == NULL) { + return ESP_ERR_INVALID_ARG; + } + return platform_ota_get_memory_headroom(&out->free_internal, + &out->largest_internal, + &out->largest_dma); +} + +void domain_runtime_restart(void) +{ + platform_restart(); +} diff --git a/components/domain/src/runtime/domain_runtime_adapters.c b/components/domain/src/runtime/domain_runtime_adapters.c index 20cf680..61ee3bb 100644 --- a/components/domain/src/runtime/domain_runtime_adapters.c +++ b/components/domain/src/runtime/domain_runtime_adapters.c @@ -859,6 +859,11 @@ void domain_runtime_read_device_identity(char *device_id, platform_read_device_identity(device_id, device_id_len, serial_number, serial_number_len); } +esp_err_t domain_runtime_read_firmware_version(char *out, size_t out_len) +{ + return platform_read_firmware_version(out, out_len); +} + esp_err_t domain_runtime_write_device_identity(const char *device_id, const char *serial_number) { return platform_write_device_identity(device_id, serial_number); diff --git a/components/platform/CMakeLists.txt b/components/platform/CMakeLists.txt index 8f31ae8..8dcfcc6 100644 --- a/components/platform/CMakeLists.txt +++ b/components/platform/CMakeLists.txt @@ -3,6 +3,7 @@ set(platform_srcs "src/runtime/platform_storage.c" "src/runtime/platform_time.c" "src/runtime/platform_https_client.c" + "src/runtime/platform_ota.c" "src/runtime/platform_lan_http_server.c" "src/runtime/platform_ble_gatt_server.c" "src/platform_console_driver.c" @@ -37,11 +38,13 @@ set(platform_priv_requires esp_driver_spi esp_event esp_app_format + app_update esp_http_client esp_http_server esp_lcd esp_netif esp_psram + heap esp_system esp_timer esp_wifi diff --git a/components/platform/include/platform.h b/components/platform/include/platform.h index 23d93a0..391c57f 100644 --- a/components/platform/include/platform.h +++ b/components/platform/include/platform.h @@ -137,6 +137,11 @@ typedef struct { int status_code; } platform_https_response_t; +typedef void (*platform_ota_progress_callback_t)(uint64_t bytes_received, + uint64_t total_bytes, + void *user_data); +typedef bool (*platform_ota_cancel_callback_t)(void *user_data); + #define PLATFORM_BLE_UUID128_LEN 16 #define PLATFORM_BLE_GATT_NAME_MAX_LEN 31 #define PLATFORM_BLE_GATT_VALUE_MAX_LEN 512 @@ -252,6 +257,19 @@ bool platform_time_is_synced(void); */ esp_err_t platform_https_post_json(const platform_https_post_json_request_t *request, platform_https_response_t *response); + +esp_err_t platform_ota_install_image(const char *url, + const uint8_t expected_sha256[32], + uint64_t expected_size, + int timeout_ms, + platform_ota_progress_callback_t progress_cb, + platform_ota_cancel_callback_t cancel_cb, + void *user_data); +esp_err_t platform_ota_confirm_running_image(void); +esp_err_t platform_ota_get_memory_headroom(uint32_t *free_internal, + uint32_t *largest_internal, + uint32_t *largest_dma); +void platform_restart(void); bool platform_https_is_connect_error(esp_err_t err); /** Start a generic single-service BLE GATT peripheral. */ diff --git a/components/platform/src/runtime/platform_ota.c b/components/platform/src/runtime/platform_ota.c new file mode 100644 index 0000000..6d1e6f1 --- /dev/null +++ b/components/platform/src/runtime/platform_ota.c @@ -0,0 +1,298 @@ +/* + * SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "platform.h" + +#include +#include +#include + +#include "esp_crt_bundle.h" +#include "esp_heap_caps.h" +#include "esp_http_client.h" +#include "esp_log.h" +#include "esp_ota_ops.h" +#include "esp_system.h" +#include "esp_timer.h" +#include "mbedtls/sha256.h" + +#define PLATFORM_OTA_DOWNLOAD_BUFFER_SIZE 4096 +#define PLATFORM_OTA_MAX_REDIRECTS 5 +#define PLATFORM_OTA_IO_TIMEOUT_MS 10000 + +static const char *TAG = "platform_ota"; + +static bool platform_ota_deadline_expired(int64_t deadline_us) +{ + return esp_timer_get_time() >= deadline_us; +} + +static int platform_ota_remaining_timeout_ms(int64_t deadline_us) +{ + int64_t remaining_us = deadline_us - esp_timer_get_time(); + if (remaining_us <= 0) { + return 0; + } + int64_t remaining_ms = (remaining_us + 999) / 1000; + return remaining_ms < PLATFORM_OTA_IO_TIMEOUT_MS ? + (int)remaining_ms : PLATFORM_OTA_IO_TIMEOUT_MS; +} + +esp_err_t platform_ota_install_image(const char *url, + const uint8_t expected_sha256[32], + uint64_t expected_size, + int timeout_ms, + platform_ota_progress_callback_t progress_cb, + platform_ota_cancel_callback_t cancel_cb, + void *user_data) +{ + if (url == NULL || strncmp(url, "https://", 8) != 0 || + expected_sha256 == NULL || expected_size == 0U || timeout_ms <= 0) { + return ESP_ERR_INVALID_ARG; + } + + const esp_partition_t *partition = esp_ota_get_next_update_partition(NULL); + if (partition == NULL || expected_size > partition->size) { + ESP_LOGE(TAG, + "OTA image does not fit inactive partition: image=%" PRIu64, + expected_size); + return ESP_ERR_INVALID_SIZE; + } + + const int64_t deadline_us = esp_timer_get_time() + (int64_t)timeout_ms * 1000; + int io_timeout_ms = timeout_ms < PLATFORM_OTA_IO_TIMEOUT_MS ? + timeout_ms : PLATFORM_OTA_IO_TIMEOUT_MS; + esp_http_client_config_t http_config = { + .url = url, + .method = HTTP_METHOD_GET, + .timeout_ms = io_timeout_ms, + .crt_bundle_attach = esp_crt_bundle_attach, + .buffer_size = PLATFORM_OTA_DOWNLOAD_BUFFER_SIZE, + .max_redirection_count = PLATFORM_OTA_MAX_REDIRECTS, + .keep_alive_enable = true, + }; + + esp_http_client_handle_t client = esp_http_client_init(&http_config); + if (client == NULL) { + return ESP_ERR_NO_MEM; + } + + esp_err_t err = esp_http_client_open(client, 0); + if (err != ESP_OK) { + ESP_LOGW(TAG, "OTA HTTPS open failed: %s", esp_err_to_name(err)); + esp_http_client_cleanup(client); + return err; + } + + io_timeout_ms = platform_ota_remaining_timeout_ms(deadline_us); + if (io_timeout_ms <= 0) { + esp_http_client_close(client); + esp_http_client_cleanup(client); + return ESP_ERR_TIMEOUT; + } + esp_http_client_set_timeout_ms(client, io_timeout_ms); + + int64_t content_length = esp_http_client_fetch_headers(client); + if (content_length < 0) { + int transport_errno = esp_http_client_get_errno(client); + ESP_LOGW(TAG, + "OTA response header fetch failed: result=%" PRIi64 " errno=%d", + content_length, + transport_errno); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return ESP_ERR_INVALID_RESPONSE; + } + int status_code = esp_http_client_get_status_code(client); + if (status_code != 200) { + ESP_LOGW(TAG, "OTA download returned HTTP %d", status_code); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return ESP_ERR_INVALID_RESPONSE; + } + if (content_length >= 0 && (uint64_t)content_length != expected_size) { + ESP_LOGE(TAG, + "OTA Content-Length mismatch: expected=%" PRIu64 " actual=%" PRIi64, + expected_size, + content_length); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return ESP_ERR_INVALID_SIZE; + } + + esp_ota_handle_t ota_handle = 0; + err = esp_ota_begin(partition, (size_t)expected_size, &ota_handle); + if (err != ESP_OK) { + esp_http_client_close(client); + esp_http_client_cleanup(client); + return err; + } + + uint8_t *buffer = (uint8_t *)malloc(PLATFORM_OTA_DOWNLOAD_BUFFER_SIZE); + if (buffer == NULL) { + esp_ota_abort(ota_handle); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return ESP_ERR_NO_MEM; + } + + mbedtls_sha256_context sha_ctx; + mbedtls_sha256_init(&sha_ctx); + if (mbedtls_sha256_starts(&sha_ctx, 0) != 0) { + err = ESP_FAIL; + goto cleanup; + } + + uint64_t received = 0U; + while (received < expected_size) { + bool cancelled = cancel_cb != NULL && cancel_cb(user_data); + if (cancelled || platform_ota_deadline_expired(deadline_us)) { + err = cancelled ? ESP_ERR_INVALID_STATE : ESP_ERR_TIMEOUT; + goto cleanup_sha; + } + + io_timeout_ms = platform_ota_remaining_timeout_ms(deadline_us); + if (io_timeout_ms <= 0) { + err = ESP_ERR_TIMEOUT; + goto cleanup_sha; + } + esp_http_client_set_timeout_ms(client, io_timeout_ms); + + size_t remaining = (size_t)(expected_size - received); + int read_capacity = remaining < PLATFORM_OTA_DOWNLOAD_BUFFER_SIZE ? + (int)remaining : PLATFORM_OTA_DOWNLOAD_BUFFER_SIZE; + int read_len = esp_http_client_read(client, (char *)buffer, read_capacity); + if (read_len == -ESP_ERR_HTTP_EAGAIN) { + continue; + } + if (read_len < 0) { + err = ESP_ERR_INVALID_RESPONSE; + goto cleanup_sha; + } + if (read_len == 0) { + err = esp_http_client_is_complete_data_received(client) ? + ESP_ERR_INVALID_SIZE : ESP_ERR_HTTP_EAGAIN; + goto cleanup_sha; + } + + if (mbedtls_sha256_update(&sha_ctx, buffer, (size_t)read_len) != 0) { + err = ESP_FAIL; + goto cleanup_sha; + } + err = esp_ota_write(ota_handle, buffer, (size_t)read_len); + if (err != ESP_OK) { + goto cleanup_sha; + } + received += (uint64_t)read_len; + if (progress_cb != NULL) { + progress_cb(received, expected_size, user_data); + } + } + + if (content_length < 0) { + while (!esp_http_client_is_complete_data_received(client)) { + io_timeout_ms = platform_ota_remaining_timeout_ms(deadline_us); + if (io_timeout_ms <= 0) { + err = ESP_ERR_TIMEOUT; + goto cleanup_sha; + } + esp_http_client_set_timeout_ms(client, io_timeout_ms); + int overflow_len = esp_http_client_read(client, (char *)buffer, 1); + if (overflow_len == -ESP_ERR_HTTP_EAGAIN) { + continue; + } + if (overflow_len < 0) { + err = ESP_ERR_INVALID_RESPONSE; + goto cleanup_sha; + } + if (overflow_len > 0) { + err = ESP_ERR_INVALID_SIZE; + goto cleanup_sha; + } + break; + } + } + + uint8_t actual_sha256[32]; + if (mbedtls_sha256_finish(&sha_ctx, actual_sha256) != 0) { + err = ESP_FAIL; + goto cleanup_sha; + } + mbedtls_sha256_free(&sha_ctx); + + if (memcmp(actual_sha256, expected_sha256, sizeof(actual_sha256)) != 0) { + ESP_LOGE(TAG, "OTA SHA-256 mismatch"); + err = ESP_ERR_INVALID_CRC; + goto cleanup; + } + + err = esp_ota_end(ota_handle); + ota_handle = 0; + if (err != ESP_OK) { + ESP_LOGE(TAG, "OTA image validation failed: %s", esp_err_to_name(err)); + goto cleanup_no_abort; + } + + err = esp_ota_set_boot_partition(partition); + if (err == ESP_OK) { + ESP_LOGI(TAG, + "OTA image installed in %s (%" PRIu64 " bytes)", + partition->label, + received); + } + goto cleanup_no_abort; + +cleanup_sha: + mbedtls_sha256_free(&sha_ctx); +cleanup: + if (ota_handle != 0) { + esp_ota_abort(ota_handle); + } +cleanup_no_abort: + free(buffer); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return err; +} + +esp_err_t platform_ota_confirm_running_image(void) +{ + const esp_partition_t *running = esp_ota_get_running_partition(); + if (running == NULL) { + return ESP_ERR_NOT_FOUND; + } + + esp_ota_img_states_t state = ESP_OTA_IMG_UNDEFINED; + esp_err_t err = esp_ota_get_state_partition(running, &state); + if (err == ESP_ERR_NOT_FOUND || state != ESP_OTA_IMG_PENDING_VERIFY) { + return ESP_OK; + } + if (err != ESP_OK) { + return err; + } + return esp_ota_mark_app_valid_cancel_rollback(); +} + +esp_err_t platform_ota_get_memory_headroom(uint32_t *free_internal, + uint32_t *largest_internal, + uint32_t *largest_dma) +{ + if (free_internal == NULL || largest_internal == NULL || largest_dma == NULL) { + return ESP_ERR_INVALID_ARG; + } + + *free_internal = (uint32_t)heap_caps_get_free_size( + MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + *largest_internal = (uint32_t)heap_caps_get_largest_free_block( + MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + *largest_dma = (uint32_t)heap_caps_get_largest_free_block(MALLOC_CAP_DMA); + return ESP_OK; +} + +void platform_restart(void) +{ + esp_restart(); +} diff --git a/docs/ota-firmware-integration.md b/docs/ota-firmware-integration.md new file mode 100644 index 0000000..c7c65a6 --- /dev/null +++ b/docs/ota-firmware-integration.md @@ -0,0 +1,31 @@ +# OTA Firmware Integration + +The control plane owns a managed OTA worker. After Wi-Fi is available, it +obtains a device JWT with the `ota:report` scope and posts the running device +identity and firmware version to `CONFIG_TQ_OTA_REPORT_URL`. + +The service returns `204` when the running image is current. A `200` response +with `data.notUptodate=1` includes the target version, HTTPS download URL, +exact byte size, SHA-256, and the `force`/`update` decision. Non-forced images +wait for the mini-program confirmation (`update=1`); forced images proceed +without that confirmation. + +Before an HTTPS exchange, the worker quiesces local wake and multimodal voice +services, waits for transport teardown, and checks internal/DMA memory +headroom. The platform layer streams the image into the inactive OTA +partition, verifies the exact byte count and raw-file SHA-256, validates the +ESP-IDF image, selects the new boot partition, and restarts. Any failure +aborts the OTA handle and leaves the current partition active. + +The first normal runtime startup of a pending image confirms it with the +ESP-IDF rollback API. A failed or unhealthy boot therefore rolls back to the +previous image. + +The running version is embedded in the application descriptor at build time. +Update the repository-root `version.txt` before publishing a release; the +device reads it at runtime through `esp_app_get_description()->version`. + +The device report route should normally be exempted from service-to-service +HMAC authentication while retaining device JWT authentication. The optional +`CONFIG_TQ_OTA_INTERNAL_AUTH_SECRET` exists only for deployments that require +the documented `X-Internal-*` headers. diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index bab1aea..26cce5d 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -43,6 +43,80 @@ menu "Example Configuration" firmware uses a longer timeout to tolerate brief beacon loss on crowded 2.4 GHz networks. + menu "OTA Firmware Update" + + config TQ_OTA_ENABLE + bool "Enable managed OTA firmware updates" + default y + depends on AUTH_V2_ENABLE + select BOOTLOADER_APP_ROLLBACK_ENABLE + + config TQ_OTA_REPORT_URL + string "OTA version report URL" + default "https://gz.api.talkingq.com/ota/api/ota/versionInfoReport" + depends on TQ_OTA_ENABLE + + config TQ_OTA_INTERNAL_AUTH_SECRET + string "OTA internal-auth compatibility secret" + default "" + depends on TQ_OTA_ENABLE + + config TQ_OTA_POLL_INTERVAL_MS + int "Normal OTA poll interval (ms)" + default 21600000 + range 10000 86400000 + depends on TQ_OTA_ENABLE + + config TQ_OTA_CONFIRMATION_POLL_MS + int "OTA confirmation recheck interval (ms)" + default 21600000 + range 2000 86400000 + depends on TQ_OTA_ENABLE + + config TQ_OTA_HTTP_TIMEOUT_MS + int "OTA report HTTP timeout (ms)" + default 15000 + range 1000 60000 + depends on TQ_OTA_ENABLE + + config TQ_OTA_INSTALL_TIMEOUT_MS + int "OTA download/install deadline (ms)" + default 600000 + range 30000 1800000 + depends on TQ_OTA_ENABLE + + config TQ_OTA_RETRY_BASE_MS + int "OTA retry backoff base (ms)" + default 21600000 + range 1000 86400000 + depends on TQ_OTA_ENABLE + + config TQ_OTA_RETRY_MAX_MS + int "OTA retry backoff cap (ms)" + default 21600000 + range 10000 86400000 + depends on TQ_OTA_ENABLE + + config TQ_OTA_TASK_STACK_BYTES + int "OTA worker stack (bytes)" + default 16384 + range 8192 32768 + depends on TQ_OTA_ENABLE + + config TQ_OTA_MIN_FREE_INTERNAL_BYTES + int "OTA HTTPS minimum free internal memory (bytes)" + default 32768 + range 16384 131072 + depends on TQ_OTA_ENABLE + + config TQ_OTA_MIN_LARGEST_DMA_BYTES + int "OTA HTTPS minimum largest DMA block (bytes)" + default 4096 + range 2048 32768 + depends on TQ_OTA_ENABLE + + endmenu + endmenu menu "Mini Program Wi-Fi Provisioning" diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 0340c22..cf5fbf2 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -1,6 +1,7 @@ CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y CONFIG_ESPTOOLPY_FLASHSIZE="16MB" CONFIG_ESPTOOLPY_HEADER_FLASHSIZE_UPDATE=y +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y # Mini-program BLE provisioning. CONFIG_BT_ENABLED=y @@ -85,6 +86,17 @@ CONFIG_TQ_PTT_MODE_CLICK_TO_TALK=y # CONFIG_TQ_PTT_MODE_HOLD_TO_TALK is not set CONFIG_TQ_PTT_CLICK_CAPTURE_TIMEOUT_MS=8000 CONFIG_TQ_LOCAL_WAKE_ENABLE=y +CONFIG_TQ_OTA_ENABLE=y +CONFIG_TQ_OTA_REPORT_URL="https://gz.api.talkingq.com/ota/api/ota/versionInfoReport" +CONFIG_TQ_OTA_POLL_INTERVAL_MS=21600000 +CONFIG_TQ_OTA_CONFIRMATION_POLL_MS=21600000 +CONFIG_TQ_OTA_HTTP_TIMEOUT_MS=15000 +CONFIG_TQ_OTA_INSTALL_TIMEOUT_MS=600000 +CONFIG_TQ_OTA_RETRY_BASE_MS=21600000 +CONFIG_TQ_OTA_RETRY_MAX_MS=21600000 +CONFIG_TQ_OTA_TASK_STACK_BYTES=16384 +CONFIG_TQ_OTA_MIN_FREE_INTERNAL_BYTES=32768 +CONFIG_TQ_OTA_MIN_LARGEST_DMA_BYTES=4096 CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_ENABLE=y CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_FILE="local_wake_response.opus" CONFIG_TQ_LOCAL_WAKE_RETRY_MS=5000 diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +1.0.0