diff --git a/build/TianShanOS.bin b/build/TianShanOS.bin index 0a1629f..8061ffd 100644 Binary files a/build/TianShanOS.bin and b/build/TianShanOS.bin differ diff --git a/build/bootloader/bootloader.bin b/build/bootloader/bootloader.bin index d695837..b200e69 100644 Binary files a/build/bootloader/bootloader.bin and b/build/bootloader/bootloader.bin differ diff --git a/build/www.bin b/build/www.bin index 5792ef4..5a86540 100644 Binary files a/build/www.bin and b/build/www.bin differ diff --git a/components/ts_ota/include/ts_ota.h b/components/ts_ota/include/ts_ota.h index 7521d8a..30f142c 100644 --- a/components/ts_ota/include/ts_ota.h +++ b/components/ts_ota/include/ts_ota.h @@ -175,6 +175,32 @@ esp_err_t ts_ota_deinit(void); */ esp_err_t ts_ota_start(const ts_ota_config_t *config); +/** + * @brief Synchronously flash app firmware from a local file + * + * This call only returns ESP_OK after the firmware image has been written to + * the next OTA partition and that partition has been selected for next boot. + * + * @param filepath Path to TianShanOS.bin + * @param allow_downgrade Allow flashing a version lower than the running app + * @param progress_cb Progress callback (optional) + * @param user_data User data for callback + * @return ESP_OK when the app partition write has completed successfully + */ +esp_err_t ts_ota_flash_file_sync(const char *filepath, + bool allow_downgrade, + ts_ota_progress_cb_t progress_cb, + void *user_data); + +/** + * @brief Check if SD card/local-file app OTA is running + * + * Covers both asynchronous SD-card OTA and synchronous local-file flashing. + * + * @return true when app partition is being written from a local file + */ +bool ts_ota_sdcard_is_running(void); + /** * @brief Abort ongoing OTA update * @@ -399,6 +425,13 @@ bool ts_ota_https_is_running(void); // WWW Partition OTA API // ============================================================================ +/** + * @brief Canonical recovery image paths + */ +#define TS_OTA_RECOVERY_FIRMWARE_PATH "/sdcard/recovery/TianShanOS.bin" +#define TS_OTA_RECOVERY_WWW_PATH "/sdcard/recovery/www.bin" +#define TS_OTA_RECOVERY_MANIFEST_PATH "/sdcard/recovery/manifest.json" + /** * @brief Initialize WWW OTA module * @return ESP_OK on success @@ -434,6 +467,21 @@ esp_err_t ts_ota_www_start_sdcard(const char *filepath, ts_ota_progress_cb_t progress_cb, void *user_data); +/** + * @brief Synchronously flash WWW partition from a local file + * + * This call only returns ESP_OK after the file has been written to the www + * partition and the WebUI OTA progress state is pending reboot. + * + * @param filepath Path to www.bin file + * @param progress_cb Progress callback (optional) + * @param user_data User data for callback + * @return ESP_OK when the partition write has completed successfully + */ +esp_err_t ts_ota_www_flash_file_sync(const char *filepath, + ts_ota_progress_cb_t progress_cb, + void *user_data); + /** * @brief Abort WWW partition OTA * @return ESP_OK on success @@ -490,6 +538,19 @@ esp_err_t ts_ota_check_recovery(void); */ esp_err_t ts_ota_create_recovery_manifest(const char *version, bool force); +/** + * @brief Write recovery manifest from files currently in /sdcard/recovery + * + * Browser-proxy OTA uses this to keep recovery files as cached artifacts + * without arming boot-time recovery. + * + * @param include_www Include /sdcard/recovery/www.bin in the manifest + * @param force Force recovery regardless of version when armed + * @param armed Allow boot-time recovery to consume this manifest + * @return ESP_OK on success + */ +esp_err_t ts_ota_write_recovery_manifest_from_files(bool include_www, bool force, bool armed); + // ============================================================================ // Unified Download API (Recovery Directory Mode) // ============================================================================ diff --git a/components/ts_ota/src/ts_ota_download.c b/components/ts_ota/src/ts_ota_download.c index daff5e7..c3b8168 100644 --- a/components/ts_ota/src/ts_ota_download.c +++ b/components/ts_ota/src/ts_ota_download.c @@ -36,9 +36,9 @@ static const char *TAG = "ts_ota_download"; /* Recovery 目录路径 */ #define RECOVERY_DIR "/sdcard/recovery" -#define RECOVERY_FIRMWARE RECOVERY_DIR "/TianShanOS.bin" -#define RECOVERY_WWW RECOVERY_DIR "/www.bin" -#define RECOVERY_MANIFEST RECOVERY_DIR "/manifest.json" +#define RECOVERY_FIRMWARE TS_OTA_RECOVERY_FIRMWARE_PATH +#define RECOVERY_WWW TS_OTA_RECOVERY_WWW_PATH +#define RECOVERY_MANIFEST TS_OTA_RECOVERY_MANIFEST_PATH #define RECOVERY_TEMP RECOVERY_DIR "/temp.bin" /* PSRAM-first allocation */ @@ -69,8 +69,9 @@ static bool s_download_running = false; static void download_task(void *arg); static esp_err_t download_file_to_sdcard(const char *url, const char *dest_path, bool skip_cert_verify, size_t *out_size); +static esp_err_t read_firmware_file_version(const char *path, char *version, size_t version_len); static esp_err_t verify_firmware_file(const char *path, char *version, size_t version_len); -static esp_err_t create_manifest_file(const char *firmware_version, const char *www_version, bool force); +static esp_err_t create_manifest_file(const char *firmware_version, const char *www_name, bool force, bool armed); /** @@ -249,10 +250,12 @@ esp_err_t ts_ota_save_upload(const void *data, size_t len, bool is_firmware, boo return ret; } - // 创建 manifest - ret = create_manifest_file(version, NULL, true); - if (ret != ESP_OK) { - ESP_LOGW(TAG, "Failed to create manifest (non-fatal)"); + if (auto_flash) { + // Keep legacy auto-flash behavior armed for boot-time recovery. + ret = create_manifest_file(version, NULL, true, true); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "Failed to create manifest (non-fatal)"); + } } } @@ -268,6 +271,37 @@ esp_err_t ts_ota_save_upload(const void *data, size_t len, bool is_firmware, boo return ESP_OK; } +esp_err_t ts_ota_write_recovery_manifest_from_files(bool include_www, bool force, bool armed) +{ + esp_err_t ret = ensure_recovery_dir(); + if (ret != ESP_OK) { + return ret; + } + + char version[64] = {0}; + ret = read_firmware_file_version(RECOVERY_FIRMWARE, version, sizeof(version)); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Recovery firmware is not valid for manifest: %s", esp_err_to_name(ret)); + return ret; + } + + const char *www_name = ""; + if (include_www) { + struct stat st; + if (stat(RECOVERY_WWW, &st) != 0) { + ESP_LOGE(TAG, "Recovery www not found for manifest: %s", RECOVERY_WWW); + return ESP_ERR_NOT_FOUND; + } + if (st.st_size <= 0) { + ESP_LOGE(TAG, "Recovery www is empty"); + return ESP_ERR_INVALID_SIZE; + } + www_name = "www.bin"; + } + + return create_manifest_file(version, www_name, force, armed); +} + /** * @brief 从 recovery 目录刷入固件 * @@ -424,8 +458,8 @@ static void download_task(void *arg) ESP_LOGI(TAG, "Firmware verified: version=%s", version); - // 创建 manifest - create_manifest_file(version, NULL, false); + // Download-only recovery is an armed app-only package. + create_manifest_file(version, "", false, true); } ts_ota_update_progress(TS_OTA_STATE_IDLE, file_size, file_size, "下载完成"); @@ -697,14 +731,10 @@ static esp_err_t download_file_to_sdcard(const char *url, const char *dest_path, return (ret == ESP_OK) ? ESP_FAIL : ret; } -/** - * @brief 验证固件文件 - */ -static esp_err_t verify_firmware_file(const char *path, char *version, size_t version_len) +static esp_err_t read_firmware_file_version(const char *path, char *version, size_t version_len) { FILE *f = fopen(path, "rb"); if (!f) { - ts_ota_set_error(TS_OTA_ERR_FILE_NOT_FOUND, "固件文件不存在"); return ESP_ERR_NOT_FOUND; } @@ -713,7 +743,6 @@ static esp_err_t verify_firmware_file(const char *path, char *version, size_t ve uint8_t *header = OTA_MALLOC(header_size); if (!header) { fclose(f); - ts_ota_set_error(TS_OTA_ERR_INTERNAL, "内存不足"); return ESP_ERR_NO_MEM; } @@ -722,7 +751,6 @@ static esp_err_t verify_firmware_file(const char *path, char *version, size_t ve if (read < header_size) { OTA_FREE(header); - ts_ota_set_error(TS_OTA_ERR_VERIFY_FAILED, "固件文件过小"); return ESP_ERR_INVALID_SIZE; } @@ -731,7 +759,6 @@ static esp_err_t verify_firmware_file(const char *path, char *version, size_t ve if (app_desc->magic_word != ESP_APP_DESC_MAGIC_WORD) { OTA_FREE(header); - ts_ota_set_error(TS_OTA_ERR_VERIFY_FAILED, "无效的固件格式"); return ESP_ERR_INVALID_ARG; } @@ -747,10 +774,28 @@ static esp_err_t verify_firmware_file(const char *path, char *version, size_t ve return ESP_OK; } +/** + * @brief 验证固件文件 + */ +static esp_err_t verify_firmware_file(const char *path, char *version, size_t version_len) +{ + esp_err_t ret = read_firmware_file_version(path, version, version_len); + if (ret == ESP_ERR_NOT_FOUND) { + ts_ota_set_error(TS_OTA_ERR_FILE_NOT_FOUND, "固件文件不存在"); + } else if (ret == ESP_ERR_NO_MEM) { + ts_ota_set_error(TS_OTA_ERR_INTERNAL, "内存不足"); + } else if (ret == ESP_ERR_INVALID_SIZE) { + ts_ota_set_error(TS_OTA_ERR_VERIFY_FAILED, "固件文件过小"); + } else if (ret == ESP_ERR_INVALID_ARG) { + ts_ota_set_error(TS_OTA_ERR_VERIFY_FAILED, "无效的固件格式"); + } + return ret; +} + /** * @brief 创建 manifest 文件 */ -static esp_err_t create_manifest_file(const char *firmware_version, const char *www_version, bool force) +static esp_err_t create_manifest_file(const char *firmware_version, const char *www_name, bool force, bool armed) { cJSON *manifest = cJSON_CreateObject(); if (!manifest) { @@ -762,10 +807,10 @@ static esp_err_t create_manifest_file(const char *firmware_version, const char * cJSON_AddStringToObject(manifest, "firmware_version", firmware_version); cJSON_AddStringToObject(manifest, "firmware", "TianShanOS.bin"); } - if (www_version) { - cJSON_AddStringToObject(manifest, "www_version", www_version); - cJSON_AddStringToObject(manifest, "www", "www.bin"); + if (www_name) { + cJSON_AddStringToObject(manifest, "www", www_name); } + cJSON_AddBoolToObject(manifest, "armed", armed); cJSON_AddBoolToObject(manifest, "force", force); cJSON_AddBoolToObject(manifest, "delete_after", true); @@ -776,7 +821,7 @@ static esp_err_t create_manifest_file(const char *firmware_version, const char * cJSON_AddStringToObject(manifest, "firmware_sha256", sha256); } } - if (ts_storage_exists(RECOVERY_WWW)) { + if (www_name && www_name[0] && ts_storage_exists(RECOVERY_WWW)) { if (calculate_file_sha256(RECOVERY_WWW, sha256, sizeof(sha256)) == ESP_OK) { cJSON_AddStringToObject(manifest, "www_sha256", sha256); } diff --git a/components/ts_ota/src/ts_ota_recovery.c b/components/ts_ota/src/ts_ota_recovery.c index e293974..59436e8 100644 --- a/components/ts_ota/src/ts_ota_recovery.c +++ b/components/ts_ota/src/ts_ota_recovery.c @@ -17,6 +17,7 @@ * manifest.json 格式: * { * "version": "0.3.0", + * "armed": true, * "force": false, * "firmware": "TianShanOS.bin", * "www": "www.bin" @@ -49,17 +50,13 @@ static const char *TAG = "ts_recovery"; // Recovery 目录和文件路径 #define RECOVERY_DIR "/sdcard/recovery" -#define RECOVERY_MANIFEST RECOVERY_DIR "/manifest.json" -#define RECOVERY_FIRMWARE RECOVERY_DIR "/TianShanOS.bin" -#define RECOVERY_WWW RECOVERY_DIR "/www.bin" +#define RECOVERY_MANIFEST TS_OTA_RECOVERY_MANIFEST_PATH +#define RECOVERY_FIRMWARE TS_OTA_RECOVERY_FIRMWARE_PATH +#define RECOVERY_WWW TS_OTA_RECOVERY_WWW_PATH #define RECOVERY_BUFFER_SIZE 4096 -// 使用 PSRAM 如果可用 -#if CONFIG_SPIRAM -#define RECOVERY_MALLOC(size) heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT) -#else -#define RECOVERY_MALLOC(size) malloc(size) -#endif +// Flash writes must not read from PSRAM while cache is disabled. +#define RECOVERY_MALLOC(size) heap_caps_malloc(size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT) /** * @brief Recovery manifest 结构 @@ -68,6 +65,7 @@ typedef struct { char version[32]; // 目标版本 char firmware[64]; // 固件文件名 char www[64]; // WebUI 文件名 + bool armed; // 是否允许启动时自动恢复 bool force; // 强制恢复(忽略版本比对) bool delete_after; // 成功后删除 recovery 文件 } recovery_manifest_t; @@ -116,6 +114,7 @@ static esp_err_t read_manifest(recovery_manifest_t *manifest) memset(manifest, 0, sizeof(*manifest)); strcpy(manifest->firmware, "TianShanOS.bin"); strcpy(manifest->www, "www.bin"); + manifest->armed = true; manifest->delete_after = true; // 读取字段 @@ -139,6 +138,11 @@ static esp_err_t read_manifest(recovery_manifest_t *manifest) manifest->force = cJSON_IsTrue(force); } + cJSON *armed = cJSON_GetObjectItem(root, "armed"); + if (armed && cJSON_IsBool(armed)) { + manifest->armed = cJSON_IsTrue(armed); + } + cJSON *delete_after = cJSON_GetObjectItem(root, "delete_after"); if (delete_after && cJSON_IsBool(delete_after)) { manifest->delete_after = cJSON_IsTrue(delete_after); @@ -445,6 +449,7 @@ esp_err_t ts_ota_check_recovery(void) memset(&manifest, 0, sizeof(manifest)); strcpy(manifest.firmware, "TianShanOS.bin"); strcpy(manifest.www, "www.bin"); + manifest.armed = true; manifest.force = true; // 无 manifest 时强制恢复 manifest.delete_after = true; } else { @@ -453,8 +458,13 @@ esp_err_t ts_ota_check_recovery(void) } } - ESP_LOGI(TAG, "Recovery manifest: version=%s, force=%d, firmware=%s, www=%s", - manifest.version, manifest.force, manifest.firmware, manifest.www); + ESP_LOGI(TAG, "Recovery manifest: version=%s, armed=%d, force=%d, firmware=%s, www=%s", + manifest.version, manifest.armed, manifest.force, manifest.firmware, manifest.www); + + if (!manifest.armed) { + ESP_LOGI(TAG, "Recovery manifest is not armed, skipping boot-time recovery"); + return ESP_OK; + } // 检查是否需要恢复 if (!need_recovery(&manifest)) { @@ -546,6 +556,7 @@ esp_err_t ts_ota_create_recovery_manifest(const char *version, bool force) } cJSON_AddStringToObject(root, "version", version ? version : ""); + cJSON_AddBoolToObject(root, "armed", true); cJSON_AddBoolToObject(root, "force", force); cJSON_AddStringToObject(root, "firmware", "TianShanOS.bin"); cJSON_AddStringToObject(root, "www", "www.bin"); diff --git a/components/ts_ota/src/ts_ota_sdcard.c b/components/ts_ota/src/ts_ota_sdcard.c index a3ea146..e7cd846 100644 --- a/components/ts_ota/src/ts_ota_sdcard.c +++ b/components/ts_ota/src/ts_ota_sdcard.c @@ -14,6 +14,7 @@ /* PSRAM-first allocation for OTA buffers */ #define OTA_MALLOC(size) ({ void *p = heap_caps_malloc((size), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); p ? p : malloc(size); }) +#define OTA_FLASH_SYNC_BUFFER_SIZE 4096 #include "esp_app_format.h" #include "ts_ota.h" #include "ts_event.h" @@ -91,6 +92,238 @@ esp_err_t ts_ota_start_sdcard(const ts_ota_config_t *config) return ESP_OK; } +bool ts_ota_sdcard_is_running(void) +{ + return s_ota_running; +} + +/** + * @brief Synchronously flash app firmware from a local file + */ +esp_err_t ts_ota_flash_file_sync(const char *filepath, + bool allow_downgrade, + ts_ota_progress_cb_t progress_cb, + void *user_data) +{ + esp_err_t ret = ESP_OK; + FILE *f = NULL; + esp_ota_handle_t ota_handle = 0; + const esp_partition_t *update_partition = NULL; + uint8_t *buffer = NULL; + size_t file_size = 0; + + if (!filepath || strlen(filepath) == 0) { + return ESP_ERR_INVALID_ARG; + } + + if (s_ota_running) { + ESP_LOGE(TAG, "OTA already running"); + return ESP_ERR_INVALID_STATE; + } + + struct stat st; + if (stat(filepath, &st) != 0) { + ESP_LOGE(TAG, "Firmware file not found: %s", filepath); + return ESP_ERR_NOT_FOUND; + } + if (st.st_size <= 0) { + ESP_LOGE(TAG, "Invalid firmware file size: %ld", (long)st.st_size); + return ESP_ERR_INVALID_SIZE; + } + file_size = (size_t)st.st_size; + + s_ota_running = true; + ts_ota_update_progress(TS_OTA_STATE_DOWNLOADING, 0, 0, "正在读取固件文件..."); + ts_event_post(TS_EVENT_BASE_OTA, TS_EVENT_OTA_STARTED, NULL, 0, 0); + + ESP_LOGI(TAG, "Synchronously flashing firmware from: %s", filepath); + ESP_LOGI(TAG, "Firmware size: %zu bytes", file_size); + + f = fopen(filepath, "rb"); + if (!f) { + ESP_LOGE(TAG, "Failed to open firmware file"); + ts_ota_set_error(TS_OTA_ERR_FILE_NOT_FOUND, "打开固件文件失败"); + ret = ESP_ERR_NOT_FOUND; + goto cleanup; + } + + buffer = heap_caps_malloc(OTA_FLASH_SYNC_BUFFER_SIZE, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + if (!buffer) { + ESP_LOGE(TAG, "Failed to allocate internal OTA buffer"); + ts_ota_set_error(TS_OTA_ERR_INTERNAL, "内部内存不足"); + ret = ESP_ERR_NO_MEM; + goto cleanup; + } + + ts_ota_update_progress(TS_OTA_STATE_VERIFYING, 0, file_size, "正在验证固件..."); + + size_t header_size = sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t) + sizeof(esp_app_desc_t); + size_t read_len = fread(buffer, 1, header_size, f); + if (read_len < header_size) { + ESP_LOGE(TAG, "Failed to read firmware header"); + ts_ota_set_error(TS_OTA_ERR_VERIFY_FAILED, "读取固件头部失败"); + ret = ESP_ERR_INVALID_SIZE; + goto cleanup; + } + + esp_app_desc_t *app_desc = (esp_app_desc_t *)(buffer + sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t)); + if (app_desc->magic_word != ESP_APP_DESC_MAGIC_WORD) { + ESP_LOGE(TAG, "Invalid firmware magic word"); + ts_ota_set_error(TS_OTA_ERR_VERIFY_FAILED, "无效的固件格式"); + ret = ESP_ERR_INVALID_ARG; + goto cleanup; + } + + ESP_LOGI(TAG, "New firmware: %s, version: %s", app_desc->project_name, app_desc->version); + ESP_LOGI(TAG, "Compiled: %s %s", app_desc->date, app_desc->time); + +#if CONFIG_TS_OTA_VERSION_CHECK + if (!allow_downgrade) { + const esp_app_desc_t *running_app = esp_app_get_description(); + int cmp = ts_ota_compare_versions(app_desc->version, running_app->version); + if (cmp < 0) { + ESP_LOGE(TAG, "Downgrade not allowed: %s -> %s", + running_app->version, app_desc->version); + ts_ota_set_error(TS_OTA_ERR_VERSION_MISMATCH, "不允许固件降级"); + ret = ESP_ERR_INVALID_VERSION; + goto cleanup; + } + } +#endif + + update_partition = esp_ota_get_next_update_partition(NULL); + if (!update_partition) { + ESP_LOGE(TAG, "No OTA partition available"); + ts_ota_set_error(TS_OTA_ERR_NO_PARTITION, "没有可用的 OTA 分区"); + ret = ESP_ERR_NOT_FOUND; + goto cleanup; + } + + ESP_LOGI(TAG, "Target partition: %s (addr=0x%lx, size=%lu KB)", + update_partition->label, update_partition->address, + update_partition->size / 1024); + + if (file_size > update_partition->size) { + ESP_LOGE(TAG, "Firmware too large for partition"); + ts_ota_set_error(TS_OTA_ERR_PARTITION_FULL, "固件过大,超出分区容量"); + ret = ESP_ERR_INVALID_SIZE; + goto cleanup; + } + + ret = esp_ota_begin(update_partition, OTA_WITH_SEQUENTIAL_WRITES, &ota_handle); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_begin failed: %s", esp_err_to_name(ret)); + ts_ota_set_error(TS_OTA_ERR_WRITE_FAILED, "OTA 初始化失败"); + goto cleanup; + } + + fseek(f, 0, SEEK_SET); + ts_ota_update_progress(TS_OTA_STATE_WRITING, 0, file_size, "正在写入固件..."); + + size_t written = 0; + while (written < file_size) { + size_t to_read = file_size - written; + if (to_read > OTA_FLASH_SYNC_BUFFER_SIZE) { + to_read = OTA_FLASH_SYNC_BUFFER_SIZE; + } + + read_len = fread(buffer, 1, to_read, f); + if (read_len != to_read) { + ESP_LOGE(TAG, "Read error: %zu / %zu bytes", read_len, to_read); + ts_ota_set_error(TS_OTA_ERR_DOWNLOAD_FAILED, "读取文件失败"); + ret = ESP_FAIL; + goto cleanup; + } + + ret = esp_ota_write(ota_handle, buffer, read_len); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_write failed: %s", esp_err_to_name(ret)); + ts_ota_set_error(TS_OTA_ERR_WRITE_FAILED, "写入闪存失败"); + goto cleanup; + } + + written += read_len; + int percent = (written * 100) / file_size; + ESP_LOGI(TAG, "Written: %zu / %zu bytes (%d%%)", written, file_size, percent); + + ts_ota_update_progress(TS_OTA_STATE_WRITING, written, file_size, "正在写入固件..."); + if (progress_cb) { + ts_ota_progress_t progress = { + .state = TS_OTA_STATE_WRITING, + .error = TS_OTA_ERR_NONE, + .total_size = file_size, + .received_size = written, + .progress_percent = percent, + .status_msg = "正在写入..." + }; + progress_cb(&progress, user_data); + } + + ts_ota_progress_t event_progress = { + .state = TS_OTA_STATE_WRITING, + .total_size = file_size, + .received_size = written, + .progress_percent = percent, + }; + ts_event_post(TS_EVENT_BASE_OTA, TS_EVENT_OTA_PROGRESS, + &event_progress, sizeof(event_progress), 0); + + vTaskDelay(pdMS_TO_TICKS(1)); + } + + ts_ota_update_progress(TS_OTA_STATE_VERIFYING, written, file_size, "正在验证固件..."); + + ret = esp_ota_end(ota_handle); + ota_handle = 0; + if (ret != ESP_OK) { + if (ret == ESP_ERR_OTA_VALIDATE_FAILED) { + ESP_LOGE(TAG, "Image validation failed"); + ts_ota_set_error(TS_OTA_ERR_VERIFY_FAILED, "固件验证失败"); + } else { + ESP_LOGE(TAG, "esp_ota_end failed: %s", esp_err_to_name(ret)); + ts_ota_set_error(TS_OTA_ERR_WRITE_FAILED, "OTA 完成失败"); + } + goto cleanup; + } + + ret = esp_ota_set_boot_partition(update_partition); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_set_boot_partition failed: %s", esp_err_to_name(ret)); + ts_ota_set_error(TS_OTA_ERR_WRITE_FAILED, "设置启动分区失败"); + goto cleanup; + } + + ESP_LOGI(TAG, "Synchronous firmware flash completed successfully!"); + ts_ota_set_completed("升级完成,等待重启"); + ts_event_post(TS_EVENT_BASE_OTA, TS_EVENT_OTA_COMPLETED, NULL, 0, 0); + + if (progress_cb) { + ts_ota_progress_t progress = { + .state = TS_OTA_STATE_PENDING_REBOOT, + .error = TS_OTA_ERR_NONE, + .total_size = file_size, + .received_size = written, + .progress_percent = 100, + .status_msg = "升级完成,等待重启" + }; + progress_cb(&progress, user_data); + } + +cleanup: + if (f) { + fclose(f); + } + if (buffer) { + free(buffer); + } + if (ota_handle) { + esp_ota_abort(ota_handle); + } + + s_ota_running = false; + return ret; +} + /** * @brief SD Card OTA task */ diff --git a/components/ts_ota/src/ts_ota_www.c b/components/ts_ota/src/ts_ota_www.c index 72419f8..8c86b6e 100644 --- a/components/ts_ota/src/ts_ota_www.c +++ b/components/ts_ota/src/ts_ota_www.c @@ -8,6 +8,7 @@ */ #include +#include #include #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -18,6 +19,8 @@ /* PSRAM-first allocation for OTA buffers */ #define OTA_MALLOC(size) ({ void *p = heap_caps_malloc((size), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); p ? p : malloc(size); }) +#define WWW_FLASH_BUFFER_SIZE 4096 +#define WWW_FLASH_ERASE_CHUNK_SIZE (64 * 1024) #include "esp_crt_bundle.h" #include "esp_partition.h" #include "ts_ota.h" @@ -478,6 +481,186 @@ esp_err_t ts_ota_www_start_sdcard(const char *filepath, return ESP_OK; } +/** + * @brief Synchronously flash WWW partition from a local file + */ +esp_err_t ts_ota_www_flash_file_sync(const char *filepath, + ts_ota_progress_cb_t progress_cb, + void *user_data) +{ + esp_err_t ret = ESP_OK; + FILE *f = NULL; + char *buffer = NULL; + const esp_partition_t *www_partition = NULL; + size_t file_size = 0; + + if (!filepath || strlen(filepath) == 0) { + return ESP_ERR_INVALID_ARG; + } + + if (s_www_ota_running) { + ESP_LOGE(TAG, "WWW OTA already running"); + return ESP_ERR_INVALID_STATE; + } + + if (!s_www_progress_mutex) { + ret = ts_ota_www_init(); + if (ret != ESP_OK) { + return ret; + } + } + + s_www_ota_abort_requested = false; + strncpy(s_www_config.url, filepath, sizeof(s_www_config.url) - 1); + s_www_config.url[sizeof(s_www_config.url) - 1] = '\0'; + s_www_config.from_sdcard = true; + s_www_config.progress_cb = progress_cb; + s_www_config.user_data = user_data; + s_www_ota_running = true; + + ESP_LOGI(TAG, "Synchronously flashing WWW from file: %s", filepath); + www_ota_update_progress(TS_OTA_STATE_DOWNLOADING, 0, 0, "正在读取文件..."); + + struct stat st; + if (stat(filepath, &st) != 0) { + ESP_LOGE(TAG, "File not found: %s", filepath); + www_ota_update_progress(TS_OTA_STATE_ERROR, 0, 0, "WebUI 文件不存在"); + ret = ESP_ERR_NOT_FOUND; + goto cleanup; + } + + if (st.st_size <= 0) { + ESP_LOGE(TAG, "Invalid WWW file size: %ld", (long)st.st_size); + www_ota_update_progress(TS_OTA_STATE_ERROR, 0, 0, "WebUI 文件为空"); + ret = ESP_ERR_INVALID_SIZE; + goto cleanup; + } + + file_size = (size_t)st.st_size; + + www_partition = esp_partition_find_first( + ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, "www"); + if (!www_partition) { + ESP_LOGE(TAG, "WWW partition not found"); + www_ota_update_progress(TS_OTA_STATE_ERROR, 0, 0, "找不到 www 分区"); + ret = ESP_ERR_NOT_FOUND; + goto cleanup; + } + + ESP_LOGI(TAG, "WWW partition: addr=0x%lx, size=%lu", + www_partition->address, www_partition->size); + ESP_LOGI(TAG, "WWW file size: %zu bytes", file_size); + + if (file_size > www_partition->size) { + ESP_LOGE(TAG, "File too large: %zu > %lu", file_size, www_partition->size); + www_ota_update_progress(TS_OTA_STATE_ERROR, 0, 0, "文件太大,超出分区容量"); + ret = ESP_ERR_INVALID_SIZE; + goto cleanup; + } + + f = fopen(filepath, "rb"); + if (!f) { + ESP_LOGE(TAG, "Failed to open file: %s", filepath); + www_ota_update_progress(TS_OTA_STATE_ERROR, 0, 0, "打开文件失败"); + ret = ESP_FAIL; + goto cleanup; + } + + buffer = heap_caps_malloc(WWW_FLASH_BUFFER_SIZE, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + if (!buffer) { + ESP_LOGE(TAG, "Failed to allocate internal flash buffer"); + www_ota_update_progress(TS_OTA_STATE_ERROR, 0, 0, "内部内存不足"); + ret = ESP_ERR_NO_MEM; + goto cleanup; + } + + ESP_LOGI(TAG, "Erasing www partition synchronously..."); + www_ota_update_progress(TS_OTA_STATE_WRITING, 0, file_size, "正在擦除分区..."); + + size_t erased = 0; + while (erased < www_partition->size) { + if (s_www_ota_abort_requested) { + ESP_LOGI(TAG, "WWW OTA aborted during erase"); + www_ota_update_progress(TS_OTA_STATE_IDLE, 0, 0, "已中止"); + ret = ESP_ERR_INVALID_STATE; + goto cleanup; + } + + size_t remaining = www_partition->size - erased; + size_t erase_size = remaining > WWW_FLASH_ERASE_CHUNK_SIZE + ? WWW_FLASH_ERASE_CHUNK_SIZE + : remaining; + ret = esp_partition_erase_range(www_partition, erased, erase_size); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to erase partition at offset %zu: %s", + erased, esp_err_to_name(ret)); + www_ota_update_progress(TS_OTA_STATE_ERROR, 0, 0, "擦除分区失败"); + goto cleanup; + } + + erased += erase_size; + vTaskDelay(pdMS_TO_TICKS(1)); + } + + size_t written = 0; + size_t last_reported = 0; + while (written < file_size) { + if (s_www_ota_abort_requested) { + ESP_LOGI(TAG, "WWW OTA aborted during write"); + www_ota_update_progress(TS_OTA_STATE_IDLE, 0, 0, "已中止"); + ret = ESP_ERR_INVALID_STATE; + goto cleanup; + } + + size_t to_read = file_size - written; + if (to_read > WWW_FLASH_BUFFER_SIZE) { + to_read = WWW_FLASH_BUFFER_SIZE; + } + + size_t read_len = fread(buffer, 1, to_read, f); + if (read_len != to_read) { + ESP_LOGE(TAG, "Read error: %zu / %zu bytes", read_len, to_read); + www_ota_update_progress(TS_OTA_STATE_ERROR, written, file_size, "读取失败"); + ret = ESP_FAIL; + goto cleanup; + } + + ret = esp_partition_write(www_partition, written, buffer, read_len); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Write error at offset %zu: %s", written, esp_err_to_name(ret)); + www_ota_update_progress(TS_OTA_STATE_ERROR, written, file_size, "写入失败"); + goto cleanup; + } + + written += read_len; + if (written == file_size || written - last_reported >= WWW_FLASH_ERASE_CHUNK_SIZE) { + int percent = (written * 100) / file_size; + ESP_LOGI(TAG, "Written: %zu / %zu bytes (%d%%)", written, file_size, percent); + www_ota_update_progress(TS_OTA_STATE_WRITING, written, file_size, "正在写入 WebUI..."); + last_reported = written; + } + + vTaskDelay(pdMS_TO_TICKS(1)); + } + + ESP_LOGI(TAG, "Synchronous WWW flash completed! Total: %zu bytes", written); + www_ota_update_progress(TS_OTA_STATE_PENDING_REBOOT, written, written, "WebUI 升级完成"); + ts_event_post(TS_EVENT_BASE_OTA, TS_EVENT_OTA_COMPLETED, NULL, 0, 0); + +cleanup: + if (f) { + fclose(f); + } + if (buffer) { + free(buffer); + } + + s_www_ota_running = false; + s_www_ota_abort_requested = false; + s_www_ota_task_handle = NULL; + return ret; +} + /** * @brief SD Card WWW OTA task */ diff --git a/components/ts_webui/src/ts_webui_api.c b/components/ts_webui/src/ts_webui_api.c index 84f94b8..9c21db2 100644 --- a/components/ts_webui/src/ts_webui_api.c +++ b/components/ts_webui/src/ts_webui_api.c @@ -13,8 +13,12 @@ #include "ts_config_pack.h" #include "ts_ws_subscriptions.h" #include "cJSON.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" #include "esp_http_server.h" +#include "esp_system.h" #include +#include #include #include #include "esp_heap_caps.h" @@ -24,12 +28,65 @@ #define TAG "webui_api" +#define OTA_UPLOAD_REBOOT_DELAY_MS 2000 + #ifdef CONFIG_TS_WEBUI_API_PREFIX #define API_PREFIX CONFIG_TS_WEBUI_API_PREFIX #else #define API_PREFIX "/api/v1" #endif +static bool is_binary_upload_complete(ts_http_request_t *req) +{ + return req && req->req && req->body_len == (size_t)req->req->content_len; +} + +static void delayed_reboot_task(void *arg) +{ + uint32_t delay_ms = (uint32_t)(uintptr_t)arg; + vTaskDelay(pdMS_TO_TICKS(delay_ms)); + esp_restart(); +} + +static void schedule_reboot_after_response(uint32_t delay_ms) +{ + BaseType_t ret = xTaskCreate( + delayed_reboot_task, + "ota_reboot", + 2048, + (void *)(uintptr_t)delay_ms, + 1, + NULL + ); + if (ret != pdPASS) { + TS_LOGE(TAG, "Failed to create OTA reboot task"); + } +} + +static void clear_recovery_manifest(const char *reason) +{ + if (unlink(TS_OTA_RECOVERY_MANIFEST_PATH) == 0) { + TS_LOGI(TAG, "Removed recovery manifest: %s", reason); + } +} + +static void clean_recovery_after_browser_ota_failure(const char *reason) +{ + TS_LOGW(TAG, "Cleaning recovery cache after browser OTA failure: %s", reason); + esp_err_t ret = ts_ota_clean_recovery(); + if (ret != ESP_OK) { + TS_LOGW(TAG, "Recovery cleanup failed: %s", esp_err_to_name(ret)); + } +} + +static bool is_browser_ota_busy(void) +{ + return ts_ota_sdcard_is_running() + || ts_ota_https_is_running() + || ts_ota_download_is_running() + || ts_ota_www_is_running(); +} + /** * @brief Check authentication for API requests * @note Reserved for WebUI authentication feature @@ -567,10 +624,20 @@ static esp_err_t ota_firmware_upload_handler(ts_http_request_t *req, void *user_ ts_http_set_cors(req, "*"); #endif + if (!is_binary_upload_complete(req)) { + TS_LOGW(TAG, "Incomplete firmware upload: got %zu of %zu bytes", + req->body_len, req->req ? (size_t)req->req->content_len : 0); + return ts_http_send_error(req, 400, "Incomplete upload"); + } + // 检查请求体 if (!req->body || req->body_len == 0) { return ts_http_send_error(req, 400, "Empty firmware content"); } + + if (is_browser_ota_busy()) { + return ts_http_send_error(req, 409, "OTA already in progress"); + } // 获取 auto_reboot 参数 char auto_reboot_str[8] = "true"; @@ -579,31 +646,58 @@ static esp_err_t ota_firmware_upload_handler(ts_http_request_t *req, void *user_ TS_LOGI(TAG, "OTA firmware upload: %zu bytes, auto_reboot=%d", req->body_len, auto_reboot); - // 使用统一的 recovery 目录模式 - esp_err_t ret = ts_ota_save_upload(req->body, req->body_len, true, auto_reboot); + // 新固件上传开始时先移除旧 manifest 和旧 WebUI,避免失败路径留下旧恢复指令。 + clear_recovery_manifest("firmware upload"); + if (unlink(TS_OTA_RECOVERY_WWW_PATH) == 0) { + TS_LOGI(TAG, "Removed stale recovery www.bin"); + } + + // 先保存到 recovery 目录,保持恢复素材;刷写成功后再写 disabled manifest。 + esp_err_t ret = ts_ota_save_upload(req->body, req->body_len, true, false); if (ret != ESP_OK) { TS_LOGE(TAG, "ts_ota_save_upload failed: %s", esp_err_to_name(ret)); + clean_recovery_after_browser_ota_failure("firmware save failed"); + return ts_http_send_error(req, 500, "Firmware save failed"); + } + + ret = ts_ota_flash_file_sync(TS_OTA_RECOVERY_FIRMWARE_PATH, true, NULL, NULL); + if (ret != ESP_OK) { + TS_LOGE(TAG, "ts_ota_flash_file_sync failed: %s", esp_err_to_name(ret)); + clean_recovery_after_browser_ota_failure("firmware flash failed"); if (ret == ESP_ERR_INVALID_STATE) { return ts_http_send_error(req, 409, "OTA already in progress"); } - return ts_http_send_error(req, 500, "Firmware save/flash failed"); + if (ret == ESP_ERR_INVALID_SIZE || ret == ESP_ERR_INVALID_ARG) { + return ts_http_send_error(req, 400, "Invalid firmware image"); + } + return ts_http_send_error(req, 500, "Firmware flash failed"); } - - // 如果 auto_reboot=true,设备会重启,不会执行到这里 + + ret = ts_ota_write_recovery_manifest_from_files(false, false, false); + if (ret != ESP_OK) { + TS_LOGE(TAG, "failed to write disabled app-only recovery manifest: %s", esp_err_to_name(ret)); + clean_recovery_after_browser_ota_failure("firmware manifest update failed"); + return ts_http_send_error(req, 500, "Recovery manifest update failed"); + } + TS_LOGI(TAG, "OTA firmware upload successful"); // 返回成功响应 cJSON *response = cJSON_CreateObject(); cJSON_AddStringToObject(response, "status", "success"); cJSON_AddNumberToObject(response, "size", req->body_len); - cJSON_AddBoolToObject(response, "reboot_pending", auto_reboot); - cJSON_AddStringToObject(response, "message", auto_reboot ? "Firmware uploaded, rebooting..." : "Firmware uploaded, pending reboot"); + cJSON_AddBoolToObject(response, "reboot_pending", true); + cJSON_AddBoolToObject(response, "auto_reboot_requested", auto_reboot); + cJSON_AddStringToObject(response, "message", "Firmware uploaded and flashed, pending reboot"); char *json = cJSON_PrintUnformatted(response); cJSON_Delete(response); ret = ts_http_send_json(req, 200, json); free(json); + if (ret == ESP_OK && auto_reboot) { + schedule_reboot_after_response(OTA_UPLOAD_REBOOT_DELAY_MS); + } return ret; } @@ -622,18 +716,56 @@ static esp_err_t ota_www_upload_handler(ts_http_request_t *req, void *user_data) ts_http_set_cors(req, "*"); #endif + if (!is_binary_upload_complete(req)) { + TS_LOGW(TAG, "Incomplete WWW upload: got %zu of %zu bytes", + req->body_len, req->req ? (size_t)req->req->content_len : 0); + return ts_http_send_error(req, 400, "Incomplete upload"); + } + // 检查请求体 if (!req->body || req->body_len == 0) { return ts_http_send_error(req, 400, "Empty www content"); } + + if (is_browser_ota_busy()) { + return ts_http_send_error(req, 409, "OTA already in progress"); + } TS_LOGI(TAG, "WWW partition upload: %zu bytes", req->body_len); - // 使用统一的 recovery 目录模式 - esp_err_t ret = ts_ota_save_upload(req->body, req->body_len, false, true); + // WebUI 上传会重写 disabled manifest;先清掉旧 manifest 避免失败路径沿用旧恢复指令。 + clear_recovery_manifest("www upload"); + + // 先保存到 recovery 目录,保持完整恢复包;再同步刷入 www 分区。 + esp_err_t ret = ts_ota_save_upload(req->body, req->body_len, false, false); if (ret != ESP_OK) { TS_LOGE(TAG, "ts_ota_save_upload failed: %s", esp_err_to_name(ret)); - return ts_http_send_error(req, 500, "WWW partition save/flash failed"); + clean_recovery_after_browser_ota_failure("www save failed"); + return ts_http_send_error(req, 500, "WWW partition save failed"); + } + + ret = ts_ota_www_flash_file_sync(TS_OTA_RECOVERY_WWW_PATH, NULL, NULL); + if (ret != ESP_OK) { + TS_LOGE(TAG, "ts_ota_www_flash_file_sync failed: %s", esp_err_to_name(ret)); + clean_recovery_after_browser_ota_failure("www flash failed"); + if (ret == ESP_ERR_INVALID_STATE) { + return ts_http_send_error(req, 409, "WWW OTA already in progress"); + } + if (ret == ESP_ERR_INVALID_SIZE) { + return ts_http_send_error(req, 400, "Invalid WWW image size"); + } + return ts_http_send_error(req, 500, "WWW partition flash failed"); + } + + if (ts_storage_exists(TS_OTA_RECOVERY_FIRMWARE_PATH)) { + ret = ts_ota_write_recovery_manifest_from_files(true, false, false); + if (ret != ESP_OK) { + TS_LOGW(TAG, "Full recovery manifest update failed: %s", esp_err_to_name(ret)); + clean_recovery_after_browser_ota_failure("www manifest update failed"); + } + } else { + TS_LOGW(TAG, "Recovery firmware missing after WebUI upload"); + clean_recovery_after_browser_ota_failure("recovery firmware missing after www upload"); } TS_LOGI(TAG, "WWW partition upload successful"); diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index d235871..a20fac3 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -16812,7 +16812,7 @@ async function upgradeViaProxy(serverUrl) { // 调用 ESP32 上传接口(复用现有的 /api/v1/ota/firmware) // 注意:不自动重启,等 www 也完成后再重启 - const uploadResult = await uploadFirmwareToDevice(firmwareData, !includeWww); + const uploadResult = await uploadFirmwareToDevice(firmwareData, false); if (!uploadResult.success) { throw new Error(uploadResult.error || '上传固件失败'); diff --git a/version.txt b/version.txt index 8f0916f..4b9fcbe 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.5.0 +0.5.1