/* * MTG RFID Companion - OTA Firmware Update Manager * * Checks Gitea release API, downloads firmware updates, and flashes them * to the passive OTA slot with rollback protection. */ #include #include #include #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/semphr.h" #include "esp_log.h" #include "esp_system.h" #include "esp_ota_ops.h" #include "esp_partition.h" #include "esp_https_ota.h" #include "esp_http_client.h" #include "esp_crt_bundle.h" #include "esp_app_desc.h" #include "nvs_flash.h" #include "nvs.h" #include "cJSON.h" #include "wifi_manager.h" #include "rfid_manager.h" #include "ota_manager.h" static const char *TAG = "ota"; #define GITEA_RELEASES_PAGE_URL "https://git.rasmusbendtsen.dk/rasmus/MTGcompanion/releases" #define GITEA_RELEASE_API_URL "https://git.rasmusbendtsen.dk/api/v1/repos/rasmus/MTGcompanion/releases/latest" #define GITEA_FIRMWARE_FALLBACK_URL "https://git.rasmusbendtsen.dk/rasmus/MTGcompanion/releases/download/latest/mtg-rfid-companion.bin" #define GITEA_API_BUF_SIZE (5120) /* Let's Encrypt ISRG Root X1 CA Certificate (valid through 2035) */ static const char s_isrg_root_x1_pem[] = "-----BEGIN CERTIFICATE-----\n" "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\n" "TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\n" "cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4\n" "WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu\n" "ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY\n" "MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc\n" "h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+\n" "0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U\n" "A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW\n" "T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH\n" "B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC\n" "B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv\n" "KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn\n" "OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn\n" "jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw\n" "qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI\n" "rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV\n" "HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq\n" "hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL\n" "ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ\n" "3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK\n" "NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5\n" "ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur\n" "TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC\n" "jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc\n" "oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq\n" "4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA\n" "mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d\n" "emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n" "-----END CERTIFICATE-----\n"; static SemaphoreHandle_t s_ota_mux = NULL; static ota_status_t s_status = OTA_STATUS_IDLE; static char s_message[128] = "Idle"; static ota_release_info_t s_release = {0}; static char s_running_sha256[65] = {0}; static char s_running_elf_sha256[65] = {0}; static size_t s_bytes_read = 0; static size_t s_total_bytes = 0; static volatile bool s_cancel_requested = false; static TaskHandle_t s_ota_task_handle = NULL; static void set_state(ota_status_t st, const char *msg) { if (s_ota_mux) xSemaphoreTake(s_ota_mux, portMAX_DELAY); s_status = st; if (msg) { strncpy(s_message, msg, sizeof(s_message) - 1); s_message[sizeof(s_message) - 1] = '\0'; } if (s_ota_mux) xSemaphoreGive(s_ota_mux); } esp_err_t ota_manager_init(void) { if (s_ota_mux == NULL) { s_ota_mux = xSemaphoreCreateMutex(); } const esp_partition_t *running = esp_ota_get_running_partition(); esp_ota_img_states_t ota_state; if (esp_ota_get_state_partition(running, &ota_state) == ESP_OK) { if (ota_state == ESP_OTA_IMG_PENDING_VERIFY) { ESP_LOGI(TAG, "First boot of new firmware on '%s'! Validating...", running->label); esp_err_t err = esp_ota_mark_app_valid_cancel_rollback(); if (err == ESP_OK) { ESP_LOGI(TAG, "Firmware validated and rollback canceled successfully"); } else { ESP_LOGE(TAG, "Failed to validate firmware: %s", esp_err_to_name(err)); } } } /* Compute running image SHA-256 */ uint8_t sha_bytes[32]; if (esp_partition_get_sha256(running, sha_bytes) == ESP_OK) { for (int i = 0; i < 32; i++) { snprintf(s_running_sha256 + (i * 2), 3, "%02x", sha_bytes[i]); } } /* Compute running ELF SHA-256 */ esp_app_get_elf_sha256(s_running_elf_sha256, sizeof(s_running_elf_sha256)); const esp_app_desc_t *app_desc = esp_app_get_description(); ESP_LOGI(TAG, "Running firmware: ver='%s', SHA256=%.8s..., ELF=%.8s... on partition '%s'", app_desc->version, s_running_sha256, s_running_elf_sha256, running ? running->label : "unknown"); return ESP_OK; } const char *ota_manager_get_current_version(void) { return esp_app_get_description()->version; } const char *ota_manager_get_running_sha256(void) { return s_running_sha256; } ota_status_t ota_manager_get_status(void) { ota_status_t st; if (s_ota_mux) xSemaphoreTake(s_ota_mux, portMAX_DELAY); st = s_status; if (s_ota_mux) xSemaphoreGive(s_ota_mux); return st; } const char *ota_manager_get_message(void) { return s_message; } int ota_manager_get_progress(void) { if (s_total_bytes == 0) return 0; int p = (int)((s_bytes_read * 100) / s_total_bytes); if (p > 100) p = 100; return p; } void ota_manager_get_bytes(size_t *read_bytes, size_t *total_bytes) { if (s_ota_mux) xSemaphoreTake(s_ota_mux, portMAX_DELAY); if (read_bytes) *read_bytes = s_bytes_read; if (total_bytes) *total_bytes = s_total_bytes; if (s_ota_mux) xSemaphoreGive(s_ota_mux); } const ota_release_info_t *ota_manager_get_release_info(void) { return &s_release; } void ota_manager_cancel(void) { s_cancel_requested = true; } void ota_manager_reset(void) { if (s_status != OTA_STATUS_CHECKING && s_status != OTA_STATUS_DOWNLOADING) { set_state(OTA_STATUS_IDLE, "Idle"); } } static void extract_hash_from_body(const char *body, char *out_sha256, size_t sha_sz, char *out_commit, size_t com_sz) { if (out_sha256 && sha_sz > 0) out_sha256[0] = '\0'; if (out_commit && com_sz > 0) out_commit[0] = '\0'; if (!body) return; /* Look for "SHA256:" or "Image-SHA256:" or "File-SHA256:" */ const char *p = strstr(body, "SHA256:"); if (!p) p = strstr(body, "sha256:"); if (!p) p = strstr(body, "SHA-256:"); if (p) { p = strchr(p, ':'); if (p) { p++; while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') p++; size_t idx = 0; while (isxdigit((unsigned char)*p) && idx < sha_sz - 1) { out_sha256[idx++] = (char)tolower((unsigned char)*p++); } out_sha256[idx] = '\0'; } } /* Look for "Commit:" */ const char *c = strstr(body, "Commit:"); if (!c) c = strstr(body, "commit:"); if (c) { c = strchr(c, ':'); if (c) { c++; while (*c == ' ' || *c == '\t' || *c == '\r' || *c == '\n') c++; size_t idx = 0; while (isxdigit((unsigned char)*c) && idx < com_sz - 1) { out_commit[idx++] = (char)tolower((unsigned char)*c++); } out_commit[idx] = '\0'; } } } typedef struct { char *buf; int len; int max; } http_rx_buf_t; static esp_err_t http_rx_event_handler(esp_http_client_event_t *evt) { http_rx_buf_t *rx = (http_rx_buf_t *)evt->user_data; if (evt->event_id == HTTP_EVENT_ON_DATA && rx && rx->buf) { if (rx->len + evt->data_len < rx->max - 1) { memcpy(rx->buf + rx->len, evt->data, evt->data_len); rx->len += evt->data_len; rx->buf[rx->len] = '\0'; } } return ESP_OK; } static void ota_check_task(void *arg) { set_state(OTA_STATUS_CHECKING, "Connecting to Gitea..."); ESP_LOGI(TAG, "Querying Gitea release API: %s", GITEA_RELEASE_API_URL); if (wifi_manager_acquire_net_lock(pdMS_TO_TICKS(15000)) != ESP_OK) { ESP_LOGW(TAG, "Network lock busy, cannot start OTA check"); set_state(OTA_STATUS_FAILED, "Network busy"); s_ota_task_handle = NULL; vTaskDelete(NULL); return; } http_rx_buf_t rx = { .buf = malloc(GITEA_API_BUF_SIZE), .len = 0, .max = GITEA_API_BUF_SIZE, }; if (!rx.buf) { wifi_manager_release_net_lock(); set_state(OTA_STATUS_FAILED, "Out of memory"); s_ota_task_handle = NULL; vTaskDelete(NULL); return; } wifi_manager_set_power_save(false); esp_err_t ret = ESP_FAIL; int status_code = 0; for (int attempt = 1; attempt <= 3; attempt++) { rx.len = 0; rx.buf[0] = '\0'; esp_http_client_config_t cfg = { .url = GITEA_RELEASE_API_URL, .transport_type = HTTP_TRANSPORT_OVER_SSL, .timeout_ms = 15000, .event_handler = http_rx_event_handler, .user_data = &rx, .buffer_size = 1024, .cert_pem = s_isrg_root_x1_pem, .cert_len = sizeof(s_isrg_root_x1_pem), }; esp_http_client_handle_t client = esp_http_client_init(&cfg); if (!client) { ret = ESP_ERR_NO_MEM; break; } esp_http_client_set_method(client, HTTP_METHOD_GET); esp_http_client_set_header(client, "User-Agent", "MTGCompanion/1.0"); esp_http_client_set_header(client, "Accept", "application/json"); ret = esp_http_client_perform(client); status_code = esp_http_client_get_status_code(client); esp_http_client_cleanup(client); if (ret == ESP_OK && status_code == 200) { break; } if (attempt < 3) { ESP_LOGW(TAG, "Gitea check attempt %d failed: %s (status %d), retrying in 1s...", attempt, esp_err_to_name(ret), status_code); vTaskDelay(pdMS_TO_TICKS(1000)); } } wifi_manager_release_net_lock(); wifi_manager_set_power_save(true); if (ret != ESP_OK || status_code != 200) { char err_msg[64]; if (status_code == 404) { snprintf(err_msg, sizeof(err_msg), "No releases found (404)"); } else if (ret != ESP_OK) { snprintf(err_msg, sizeof(err_msg), "Network error: %s", esp_err_to_name(ret)); } else { snprintf(err_msg, sizeof(err_msg), "HTTP error %d", status_code); } ESP_LOGW(TAG, "Gitea check failed: %s (code %d)", esp_err_to_name(ret), status_code); free(rx.buf); set_state(OTA_STATUS_FAILED, err_msg); s_ota_task_handle = NULL; vTaskDelete(NULL); return; } /* Parse JSON */ cJSON *root = cJSON_Parse(rx.buf); free(rx.buf); if (!root) { set_state(OTA_STATUS_FAILED, "Invalid JSON from server"); s_ota_task_handle = NULL; vTaskDelete(NULL); return; } cJSON *tag_item = cJSON_GetObjectItem(root, "tag_name"); cJSON *title_item = cJSON_GetObjectItem(root, "name"); cJSON *body_item = cJSON_GetObjectItem(root, "body"); cJSON *commit_item = cJSON_GetObjectItem(root, "target_commitish"); const char *tag_str = (tag_item && tag_item->valuestring) ? tag_item->valuestring : "latest"; const char *title_str = (title_item && title_item->valuestring) ? title_item->valuestring : tag_str; const char *body_str = (body_item && body_item->valuestring) ? body_item->valuestring : ""; const char *target_commit = (commit_item && commit_item->valuestring) ? commit_item->valuestring : ""; char remote_sha256[68] = {0}; char remote_commit[68] = {0}; extract_hash_from_body(body_str, remote_sha256, sizeof(remote_sha256), remote_commit, sizeof(remote_commit)); if (remote_commit[0] == '\0' && strlen(target_commit) >= 7) { strncpy(remote_commit, target_commit, sizeof(remote_commit) - 1); } if (s_ota_mux) xSemaphoreTake(s_ota_mux, portMAX_DELAY); strncpy(s_release.tag_name, tag_str, sizeof(s_release.tag_name) - 1); strncpy(s_release.release_title, title_str, sizeof(s_release.release_title) - 1); s_release.download_url[0] = '\0'; s_release.binary_size = 0; /* Preferred remote hash: SHA256 if found, else Commit hash */ if (strlen(remote_sha256) > 0) { strncpy(s_release.remote_hash, remote_sha256, sizeof(s_release.remote_hash) - 1); } else if (strlen(remote_commit) > 0) { strncpy(s_release.remote_hash, remote_commit, sizeof(s_release.remote_hash) - 1); } else { strncpy(s_release.remote_hash, tag_str, sizeof(s_release.remote_hash) - 1); } if (strlen(s_running_sha256) > 0) { strncpy(s_release.local_hash, s_running_sha256, sizeof(s_release.local_hash) - 1); } else { strncpy(s_release.local_hash, esp_app_get_description()->version, sizeof(s_release.local_hash) - 1); } /* Search assets for mtg-rfid-companion.bin or *.bin */ cJSON *assets = cJSON_GetObjectItem(root, "assets"); if (assets && cJSON_IsArray(assets)) { int count = cJSON_GetArraySize(assets); for (int i = 0; i < count; i++) { cJSON *asset = cJSON_GetArrayItem(assets, i); cJSON *aname = cJSON_GetObjectItem(asset, "name"); cJSON *aurl = cJSON_GetObjectItem(asset, "browser_download_url"); cJSON *asize = cJSON_GetObjectItem(asset, "size"); if (aname && aname->valuestring && aurl && aurl->valuestring) { if (strstr(aname->valuestring, ".bin") != NULL) { const char *url_str = aurl->valuestring; const char *path = strstr(url_str, "/rasmus/MTGcompanion/releases/download/"); if (path) { snprintf(s_release.download_url, sizeof(s_release.download_url), "https://git.rasmusbendtsen.dk%s", path); } else if (strncmp(url_str, "http://", 7) == 0) { const char *p = strchr(url_str + 7, '/'); if (p) { snprintf(s_release.download_url, sizeof(s_release.download_url), "https://git.rasmusbendtsen.dk%s", p); } else { strncpy(s_release.download_url, url_str, sizeof(s_release.download_url) - 1); } } else { strncpy(s_release.download_url, url_str, sizeof(s_release.download_url) - 1); } if (asize && asize->valuedouble > 0) { s_release.binary_size = (size_t)asize->valuedouble; } if (strcmp(aname->valuestring, "mtg-rfid-companion.bin") == 0) { break; /* Exact match, stop looking */ } } } } } /* Fallback URL if not in assets list */ if (s_release.download_url[0] == '\0') { strncpy(s_release.download_url, GITEA_FIRMWARE_FALLBACK_URL, sizeof(s_release.download_url) - 1); } /* Verify if an update is available by checking hashes */ bool is_up_to_date = false; const esp_app_desc_t *running_app = esp_app_get_description(); /* 1. Match by SHA-256 (image hash) */ if (strlen(remote_sha256) > 0 && strlen(s_running_sha256) > 0) { if (strcasecmp(remote_sha256, s_running_sha256) == 0) { is_up_to_date = true; } } /* 2. Match by commit hash prefix */ if (!is_up_to_date && strlen(remote_commit) >= 7) { if (strncasecmp(remote_commit, running_app->version, 7) == 0 || strncasecmp(running_app->version, remote_commit, 7) == 0) { is_up_to_date = true; } } /* 3. Match by NVS last installed hash */ nvs_handle_t nvs_h; if (!is_up_to_date && nvs_open("ota_store", NVS_READONLY, &nvs_h) == ESP_OK) { char nvs_val[68] = {0}; size_t nvs_sz = sizeof(nvs_val); if (nvs_get_str(nvs_h, "last_hash", nvs_val, &nvs_sz) == ESP_OK) { if (strlen(remote_sha256) > 0 && strcasecmp(nvs_val, remote_sha256) == 0) { is_up_to_date = true; } else if (strlen(remote_commit) > 0 && strncasecmp(nvs_val, remote_commit, 7) == 0) { is_up_to_date = true; } } nvs_close(nvs_h); } s_release.is_newer = !is_up_to_date; if (s_ota_mux) xSemaphoreGive(s_ota_mux); ESP_LOGI(TAG, "Gitea check: remote_hash='%.8s' (SHA256: '%.8s', Commit: '%.8s'), local_sha256='%.8s', ver='%s' => is_newer=%d", s_release.remote_hash, remote_sha256, remote_commit, s_running_sha256, running_app->version, s_release.is_newer); cJSON_Delete(root); if (s_release.is_newer) { char msg[128]; snprintf(msg, sizeof(msg), "Update available (hash: %.8s)", s_release.remote_hash); set_state(OTA_STATUS_UPDATE_AVAILABLE, msg); } else { char msg[128]; snprintf(msg, sizeof(msg), "Firmware %.8s is up to date", s_running_sha256[0] ? s_running_sha256 : running_app->version); set_state(OTA_STATUS_UP_TO_DATE, msg); } s_ota_task_handle = NULL; vTaskDelete(NULL); } static void ota_download_task(void *arg) { s_cancel_requested = false; s_bytes_read = 0; s_total_bytes = s_release.binary_size; if (wifi_manager_acquire_net_lock(pdMS_TO_TICKS(15000)) != ESP_OK) { ESP_LOGW(TAG, "Network lock busy, cannot start OTA download"); set_state(OTA_STATUS_FAILED, "Network busy"); s_ota_task_handle = NULL; vTaskDelete(NULL); return; } set_state(OTA_STATUS_DOWNLOADING, "Starting download..."); ESP_LOGI(TAG, "Starting HTTPS OTA from: %s", s_release.download_url); wifi_manager_set_power_save(false); rfid_manager_pause(true); esp_http_client_config_t http_cfg = { .url = s_release.download_url, .transport_type = HTTP_TRANSPORT_OVER_SSL, .timeout_ms = 45000, .buffer_size = 4096, .buffer_size_tx = 1024, .cert_pem = s_isrg_root_x1_pem, .cert_len = sizeof(s_isrg_root_x1_pem), }; esp_https_ota_config_t ota_cfg = { .http_config = &http_cfg, .bulk_flash_erase = false, }; esp_https_ota_handle_t ota_handle = NULL; esp_err_t err = esp_https_ota_begin(&ota_cfg, &ota_handle); if (err != ESP_OK || ota_handle == NULL) { rfid_manager_pause(false); wifi_manager_set_power_save(true); wifi_manager_release_net_lock(); ESP_LOGE(TAG, "esp_https_ota_begin failed: %s", esp_err_to_name(err)); set_state(OTA_STATUS_FAILED, "Failed to connect to image"); s_ota_task_handle = NULL; vTaskDelete(NULL); return; } /* Pre-check: verify incoming image ELF SHA-256 against currently running */ esp_app_desc_t new_app_info; if (esp_https_ota_get_img_desc(ota_handle, &new_app_info) == ESP_OK) { const esp_app_desc_t *running = esp_app_get_description(); if (memcmp(new_app_info.app_elf_sha256, running->app_elf_sha256, sizeof(new_app_info.app_elf_sha256)) == 0) { ESP_LOGW(TAG, "Incoming image has identical ELF SHA-256 as running app - aborting flash write"); esp_https_ota_abort(ota_handle); rfid_manager_pause(false); wifi_manager_set_power_save(true); wifi_manager_release_net_lock(); set_state(OTA_STATUS_UP_TO_DATE, "Firmware is already up to date"); s_ota_task_handle = NULL; vTaskDelete(NULL); return; } } int last_logged_step = -1; while (1) { if (s_cancel_requested) { ESP_LOGW(TAG, "OTA canceled by user"); esp_https_ota_abort(ota_handle); rfid_manager_pause(false); wifi_manager_set_power_save(true); wifi_manager_release_net_lock(); set_state(OTA_STATUS_IDLE, "Update canceled"); s_ota_task_handle = NULL; vTaskDelete(NULL); return; } err = esp_https_ota_perform(ota_handle); if (err != ESP_ERR_HTTPS_OTA_IN_PROGRESS) { break; } int read = esp_https_ota_get_image_len_read(ota_handle); int total = esp_https_ota_get_image_size(ota_handle); if (read > 0) s_bytes_read = (size_t)read; if (total > 0) s_total_bytes = (size_t)total; if (total > 0) { int step = (read * 10) / total; if (step != last_logged_step) { last_logged_step = step; ESP_LOGI(TAG, "OTA download: %d%% (%d / %d KB)", (read * 100) / total, read / 1024, total / 1024); } } /* Yield every chunk to feed the task watchdog and let IDLE/Wi-Fi tasks run smoothly */ vTaskDelay(pdMS_TO_TICKS(1)); } rfid_manager_pause(false); wifi_manager_set_power_save(true); if (err == ESP_OK) { if (esp_https_ota_is_complete_data_received(ota_handle)) { esp_err_t finish_err = esp_https_ota_finish(ota_handle); wifi_manager_release_net_lock(); if (finish_err == ESP_OK) { /* Store installed release hash in NVS */ nvs_handle_t nvs_h; if (nvs_open("ota_store", NVS_READWRITE, &nvs_h) == ESP_OK) { if (s_release.remote_hash[0] != '\0') { nvs_set_str(nvs_h, "last_hash", s_release.remote_hash); nvs_commit(nvs_h); } nvs_close(nvs_h); } ESP_LOGI(TAG, "OTA upgrade successful! Rebooting in 2 seconds..."); set_state(OTA_STATUS_SUCCESS_REBOOTING, "Update complete! Rebooting..."); vTaskDelay(pdMS_TO_TICKS(2000)); esp_restart(); } else { ESP_LOGE(TAG, "esp_https_ota_finish failed: %s", esp_err_to_name(finish_err)); set_state(OTA_STATUS_FAILED, "Validation failed"); } } else { esp_https_ota_abort(ota_handle); wifi_manager_release_net_lock(); set_state(OTA_STATUS_FAILED, "Incomplete download"); } } else { ESP_LOGE(TAG, "esp_https_ota_perform failed: %s", esp_err_to_name(err)); esp_https_ota_abort(ota_handle); wifi_manager_release_net_lock(); set_state(OTA_STATUS_FAILED, "Flash write failed"); } s_ota_task_handle = NULL; vTaskDelete(NULL); } esp_err_t ota_manager_check_update_async(void) { if (s_status == OTA_STATUS_CHECKING || s_status == OTA_STATUS_DOWNLOADING) { return ESP_ERR_INVALID_STATE; } set_state(OTA_STATUS_CHECKING, "Connecting to Gitea..."); BaseType_t ret = xTaskCreatePinnedToCore( ota_check_task, "ota_check", 8192, NULL, 5, &s_ota_task_handle, 0); if (ret != pdPASS) { set_state(OTA_STATUS_FAILED, "Out of memory"); return ESP_ERR_NO_MEM; } return ESP_OK; } esp_err_t ota_manager_start_update_async(void) { if (s_status == OTA_STATUS_DOWNLOADING) { return ESP_ERR_INVALID_STATE; } if (s_release.download_url[0] == '\0') { return ESP_ERR_NOT_FOUND; } set_state(OTA_STATUS_DOWNLOADING, "Starting download..."); BaseType_t ret = xTaskCreatePinnedToCore( ota_download_task, "ota_update", 10240, NULL, 5, &s_ota_task_handle, 0); if (ret != pdPASS) { set_state(OTA_STATUS_FAILED, "Out of memory"); return ESP_ERR_NO_MEM; } return ESP_OK; }