ESP32的OTA升级详解:3. 搭建node/python服务器升级(native ota原生API)

发布于:2025-07-18 ⋅ 阅读:(15) ⋅ 点赞:(0)

一、OTA两种方式:app_updateesp_https_ota 区别

  • ESP32/ESP32-S2/ESP32-C3等可通过Wi-Fi或以太网下载新固件到OTA分区实现运行时升级。ESP-IDF提供两种OTA升级方法:

    1. 使用app_update组件的原生API
    2. 使用esp_https_ota组件的简化API(支持HTTPS升级)
  • 本次主要介绍通过app_update原生API组件进行OTA升级

二、ESP32的OTA代码

我们的目标是实现基于HTTPS协议的OTA固件升级方案,具体流程为:设备通过HTTPS请求从服务器获取最新的固件包,完成下载后将其写入指定的OTA分区,随后更新启动配置信息,最终重启系统并从新烧写的OTA分区启动更新后的应用程序。这一过程确保了固件传输的安全性和升级的可靠性,同时支持系统无缝切换到新版本。

1. 代码解析

native ota API参考链接

  • #define BUFFSIZE 1024 每次从服务器读取流大小为1024个字节

  • 证书嵌入:
    通过 CMake 将服务器的 PEM 格式公钥证书(ca_cert.pem)嵌入到固件二进制文件中。在 CMakeLists.txt 中使用 EMBED_TXTFILES 指令将证书文件编译进程序,证书数据会被存储在设备的 NVS(非易失性存储)区域。

# Embed the server root certificate into the final binary
idf_build_get_property(project_dir PROJECT_DIR)
idf_component_register(SRCS "native_ota_example.c"
                    INCLUDE_DIRS "."
                    EMBED_TXTFILES ${project_dir}/server_certs/ca_cert.pem)
  • 证书访问:
    server_cert_pem_start 指向证书数据起始地址
    server_cert_pem_end 指向证书数据结束地址
extern const uint8_t server_cert_pem_start[] asm("_binary_ca_cert_pem_start");
extern const uint8_t server_cert_pem_end[] asm("_binary_ca_cert_pem_end");

// 初始化HTTPS客户端配置
esp_http_client_config_t config = {
    .url = CONFIG_EXAMPLE_FIRMWARE_UPG_URL,
    .cert_pem = (char *)server_cert_pem_start,
    .timeout_ms = CONFIG_EXAMPLE_OTA_RECV_TIMEOUT,
    .keep_alive_enable = true,
};
  • 获取当前配置的启动分区configured、获取当前运行分区running,获取下一个更新分区update_partition
    const esp_partition_t *configured = esp_ota_get_boot_partition();
    const esp_partition_t *running = esp_ota_get_running_partition();
    update_partition = esp_ota_get_next_update_partition(NULL);
  • HTTPS的Get请求配置如下,URL在menuconfig里面填写,固件名为ota_1.bin
    // 初始化HTTP客户端配置
    esp_http_client_config_t config = {
        .url = CONFIG_EXAMPLE_FIRMWARE_UPG_URL,
        .cert_pem = (char *)server_cert_pem_start,
        .timeout_ms = CONFIG_EXAMPLE_OTA_RECV_TIMEOUT,
        .keep_alive_enable = true,
    };

在这里插入图片描述

  • 从API获取流数据时,每次读取BUFFSIZE1024个Byte
    while (1)
    {
        int data_read = esp_http_client_read(client, ota_write_data, BUFFSIZE);
  • 首先需要检验固件头数据,擦除升级的flash区域,然后开始不断连续的写入到OTA区域。看注释
// 检查OTA头部信息,只在第一次接收数据时执行
if (image_header_was_checked == false) 
{
    esp_app_desc_t new_app_info;  // 用于存储新固件的描述信息
    
    // 检查接收到的数据长度是否足够包含完整的头部信息
    // 需要包含:映像头+段头+应用描述信息
    if (data_read > sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t) + sizeof(esp_app_desc_t)) 
    {
        // 从接收数据中提取应用描述信息,跳过映像头和段头
        memcpy(&new_app_info, 
              &ota_write_data[sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t)], 
              sizeof(esp_app_desc_t));
        ESP_LOGI(TAG, "检测到新固件版本: %s", new_app_info.version);
        
        // 获取当前运行固件的版本信息
        esp_app_desc_t running_app_info;
        if (esp_ota_get_partition_description(running, &running_app_info) == ESP_OK) {
            ESP_LOGI(TAG, "当前运行版本: %s", running_app_info.version);
        }

        // 获取最后一个无效(启动失败)分区的信息
        const esp_partition_t *last_invalid_app = esp_ota_get_last_invalid_partition();
        esp_app_desc_t invalid_app_info;
        if (esp_ota_get_partition_description(last_invalid_app, &invalid_app_info) == ESP_OK) {
            ESP_LOGI(TAG, "最后无效固件版本: %s", invalid_app_info.version);
        }

        /* 版本验证逻辑 */
        // 情况1:新版本与最近失败版本相同
        if (last_invalid_app != NULL) {
            if (memcmp(invalid_app_info.version, new_app_info.version, sizeof(new_app_info.version)) == 0) {
                ESP_LOGW(TAG, "新版本与最近失败的版本相同!");
                ESP_LOGW(TAG, "上次尝试启动 %s 版本固件失败", invalid_app_info.version);
                ESP_LOGW(TAG, "系统已回滚到之前版本");
                http_cleanup(client);
                infinite_loop();  // 阻止继续升级
            }
        }

        // 情况2:新版本与当前运行版本相同(可通过配置跳过此检查)
#ifndef CONFIG_EXAMPLE_SKIP_VERSION_CHECK
        if (memcmp(new_app_info.version, running_app_info.version, sizeof(new_app_info.version)) == 0) {
            ESP_LOGW(TAG, "新版本与当前运行版本相同,终止升级");
            http_cleanup(client);
            infinite_loop();
        }
#endif

        image_header_was_checked = true;  // 标记已完成头部检查

        // 初始化OTA写入操作,这里会擦除目标分区,OTA_WITH_SEQUENTIAL_WRITES 数据将按顺序写入
        err = esp_ota_begin(update_partition, OTA_WITH_SEQUENTIAL_WRITES, &update_handle);
        if (err != ESP_OK) {
            ESP_LOGE(TAG, "esp_ota_begin 失败 (%s)", esp_err_to_name(err));
            http_cleanup(client);
            esp_ota_abort(update_handle);
            task_fatal_error();
        }
        ESP_LOGI(TAG, "OTA写入初始化成功");
    }
    else {
        // 数据长度不足错误处理
        int head = sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t) + sizeof(esp_app_desc_t);
        ESP_LOGE(TAG, "接收数据长度不足!需要%d字节,实际%d字节", head, data_read);
        http_cleanup(client);
        esp_ota_abort(update_handle);
        task_fatal_error();
    }
}

// 每次循环,写入接收到的数据块到OTA分区
err = esp_ota_write(update_handle, (const void *)ota_write_data, data_read);
if (err != ESP_OK) {
    http_cleanup(client);
    esp_ota_abort(update_handle);
    task_fatal_error();
}

// 累计已写入数据量
binary_file_length += data_read;
ESP_LOGD(TAG, "已写入数据量: %d 字节", binary_file_length);
  • 结束处理
// 结束升级
err = esp_ota_end(update_handle);
// 下一次从update_partition启动
err = esp_ota_set_boot_partition(update_partition);
// 重启
esp_restart();

2. 全部代码

/* OTA example

   This example code is in the Public Domain (or CC0 licensed, at your option.)

   Unless required by applicable law or agreed to in writing, this
   software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
   CONDITIONS OF ANY KIND, either express or implied.
*/
#include <string.h>
#include <inttypes.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_system.h"
#include "esp_event.h"
#include "esp_log.h"
#include "esp_ota_ops.h"
#include "esp_app_format.h"
#include "esp_http_client.h"
#include "esp_flash_partitions.h"
#include "esp_partition.h"
#include "nvs.h"
#include "nvs_flash.h"
#include "driver/gpio.h"
#include "protocol_examples_common.h"
#include "errno.h"
#include "esp_netif.h"
#include <netdb.h>
#include <sys/socket.h>
#include <arpa/inet.h>

#if CONFIG_EXAMPLE_CONNECT_WIFI
#include "esp_wifi.h"
#endif

#define BUFFSIZE 1024
#define HASH_LEN 32 /* SHA-256 digest length */

static const char *TAG = "native_ota_example";
/*an ota data write buffer ready to write to the flash*/
static char ota_write_data[BUFFSIZE + 1] = {0};
extern const uint8_t server_cert_pem_start[] asm("_binary_ca_cert_pem_start");
extern const uint8_t server_cert_pem_end[] asm("_binary_ca_cert_pem_end");

#define OTA_URL_SIZE 256

static void http_cleanup(esp_http_client_handle_t client)
{
    esp_http_client_close(client);
    esp_http_client_cleanup(client);
}

static void __attribute__((noreturn)) task_fatal_error(void)
{
    ESP_LOGE(TAG, "Exiting task due to fatal error...");
    (void)vTaskDelete(NULL);

    while (1)
    {
        ;
    }
}

static void print_sha256(const uint8_t *image_hash, const char *label)
{
    char hash_print[HASH_LEN * 2 + 1];
    hash_print[HASH_LEN * 2] = 0;
    for (int i = 0; i < HASH_LEN; ++i)
    {
        sprintf(&hash_print[i * 2], "%02x", image_hash[i]);
    }
    ESP_LOGI(TAG, "%s: %s", label, hash_print);
}

static void infinite_loop(void)
{
    int i = 0;
    ESP_LOGI(TAG, "When a new firmware is available on the server, press the reset button to download it");
    while (1)
    {
        ESP_LOGI(TAG, "Waiting for a new firmware ... %d", ++i);
        vTaskDelay(2000 / portTICK_PERIOD_MS);
    }
}

void network_debug_info()
{
    // 获取默认网络接口
    esp_netif_t *netif = esp_netif_get_default_netif();
    if (!netif)
    {
        ESP_LOGE(TAG, "No active network interface");
        return;
    }

    // 检查网络状态
    if (!esp_netif_is_netif_up(netif))
    {
        ESP_LOGE(TAG, "Network interface down");
        return;
    }

    // 获取DNS信息
    esp_netif_dns_info_t dns_info;
    if (esp_netif_get_dns_info(netif, ESP_NETIF_DNS_MAIN, &dns_info) == ESP_OK)
    {
        ESP_LOGI(TAG, "Main DNS: " IPSTR, IP2STR(&dns_info.ip.u_addr.ip4));
    }
}

/**
 * @brief 检查 hostname 是否可以解析
 * @param hostname 要检查的域名
 * @return
 *     - ESP_OK: 解析成功
 *     - ESP_FAIL: 解析失败
 */
esp_err_t check_hostname_resolution(const char *hostname)
{
    struct addrinfo hints = {
        .ai_family = AF_INET, // 只检查IPv4
        .ai_socktype = SOCK_STREAM,
        .ai_flags = AI_CANONNAME,
    };
    struct addrinfo *result = NULL;

    ESP_LOGI(TAG, "尝试解析: %s", hostname);

    int ret = getaddrinfo(hostname, NULL, &hints, &result);
    if (ret != 0)
    {
        ESP_LOGE(TAG, "解析失败");
        if (ret == EAI_NONAME)
        {
            ESP_LOGE(TAG, "错误: 域名不存在或无法解析 (EAI_NONAME)");
        }
        else if (ret == EAI_AGAIN)
        {
            ESP_LOGE(TAG, "错误: 临时DNS故障 (EAI_AGAIN)");
        }
        return ESP_FAIL;
    }

    // 打印解析到的IP地址
    char ip_str[INET_ADDRSTRLEN];
    struct sockaddr_in *addr = (struct sockaddr_in *)result->ai_addr;
    inet_ntop(AF_INET, &addr->sin_addr, ip_str, sizeof(ip_str));
    ESP_LOGI(TAG, "解析成功: %s -> %s", hostname, ip_str);

    freeaddrinfo(result);
    return ESP_OK;
}

static void ota_example_task(void *pvParameter)
{
    esp_err_t err;
    /* update handle : set by esp_ota_begin(), must be freed via esp_ota_end() */
    esp_ota_handle_t update_handle = 0;
    const esp_partition_t *update_partition = NULL;

    ESP_LOGI(TAG, "Starting OTA example task");

    const esp_partition_t *configured = esp_ota_get_boot_partition();
    const esp_partition_t *running = esp_ota_get_running_partition();

    // 检查配置的OTA启动分区和正在运行的分区是否相同
    if (configured != running)
    {
        ESP_LOGW(TAG, "Configured OTA boot partition at offset 0x%08" PRIx32 ", but running from offset 0x%08" PRIx32,
                 configured->address, running->address);
        ESP_LOGW(TAG, "(This can happen if either the OTA boot data or preferred boot image become corrupted somehow.)");
    }
    ESP_LOGI(TAG, "Running partition type %d subtype %d (offset 0x%08" PRIx32 ")",
             running->type, running->subtype, running->address);

    // 初始化HTTP客户端配置
    esp_http_client_config_t config = {
        .url = CONFIG_EXAMPLE_FIRMWARE_UPG_URL,
        .cert_pem = (char *)server_cert_pem_start,
        .timeout_ms = CONFIG_EXAMPLE_OTA_RECV_TIMEOUT,
        .keep_alive_enable = true,
    };

    // 添加网络诊断
    network_debug_info();

#ifdef CONFIG_EXAMPLE_FIRMWARE_UPGRADE_URL_FROM_STDIN
    char url_buf[OTA_URL_SIZE];
    // 如果配置的URL为FROM_STDIN,则从标准输入读取URL
    if (strcmp(config.url, "FROM_STDIN") == 0)
    {
        example_configure_stdin_stdout();
        fgets(url_buf, OTA_URL_SIZE, stdin);
        int len = strlen(url_buf);
        url_buf[len - 1] = '\0';
        config.url = url_buf;
    }
    else
    {
        ESP_LOGE(TAG, "Configuration mismatch: wrong firmware upgrade image url");
        abort();
    }
#endif

#ifdef CONFIG_EXAMPLE_SKIP_COMMON_NAME_CHECK
    config.skip_cert_common_name_check = true;
#endif

    // 初始化HTTP客户端
    esp_http_client_handle_t client = esp_http_client_init(&config);
    if (client == NULL)
    {
        ESP_LOGE(TAG, "Failed to initialise HTTP connection");
        task_fatal_error();
    }
    // 打开HTTP连接
    err = esp_http_client_open(client, 0);
    if (err != ESP_OK)
    {
        ESP_LOGE(TAG, "Failed to open HTTP connection: %s", esp_err_to_name(err));
        esp_http_client_cleanup(client);
        task_fatal_error();
    }
    // 获取HTTP头部信息
    esp_http_client_fetch_headers(client);

    // 获取下一个OTA更新分区
    update_partition = esp_ota_get_next_update_partition(NULL);
    assert(update_partition != NULL);
    ESP_LOGI(TAG, "Writing to partition subtype %d at offset 0x%" PRIx32,
             update_partition->subtype, update_partition->address);

    int binary_file_length = 0;
    /*deal with all receive packet*/
    bool image_header_was_checked = false;
    // 循环读取HTTP数据
    while (1)
    {
        int data_read = esp_http_client_read(client, ota_write_data, BUFFSIZE);
        if (data_read < 0)
        {
            ESP_LOGE(TAG, "Error: SSL data read error %d", data_read);
            http_cleanup(client);
            task_fatal_error();
        }
        else if (data_read > 0)
        {
            // 检查OTA头部信息
            if (image_header_was_checked == false)
            {
                esp_app_desc_t new_app_info;
                if (data_read > sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t) + sizeof(esp_app_desc_t))
                {
                    // check current version with downloading
                    memcpy(&new_app_info, &ota_write_data[sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t)], sizeof(esp_app_desc_t));
                    ESP_LOGI(TAG, "New firmware version: %s", new_app_info.version);

                    esp_app_desc_t running_app_info;
                    if (esp_ota_get_partition_description(running, &running_app_info) == ESP_OK)
                    {
                        ESP_LOGI(TAG, "Running firmware version: %s", running_app_info.version);
                    }

                    const esp_partition_t *last_invalid_app = esp_ota_get_last_invalid_partition();
                    esp_app_desc_t invalid_app_info;
                    if (esp_ota_get_partition_description(last_invalid_app, &invalid_app_info) == ESP_OK)
                    {
                        ESP_LOGI(TAG, "Last invalid firmware version: %s", invalid_app_info.version);
                    }

                    // check current version with last invalid partition
                    if (last_invalid_app != NULL)
                    {
                        if (memcmp(invalid_app_info.version, new_app_info.version, sizeof(new_app_info.version)) == 0)
                        {
                            ESP_LOGW(TAG, "New version is the same as invalid version.");
                            ESP_LOGW(TAG, "Previously, there was an attempt to launch the firmware with %s version, but it failed.", invalid_app_info.version);
                            ESP_LOGW(TAG, "The firmware has been rolled back to the previous version.");
                            http_cleanup(client);
                            infinite_loop();
                        }
                    }
#ifndef CONFIG_EXAMPLE_SKIP_VERSION_CHECK
                    if (memcmp(new_app_info.version, running_app_info.version, sizeof(new_app_info.version)) == 0)
                    {
                        ESP_LOGW(TAG, "Current running version is the same as a new. We will not continue the update.");
                        http_cleanup(client);
                        infinite_loop();
                    }
#endif

                    image_header_was_checked = true;

                    err = esp_ota_begin(update_partition, OTA_WITH_SEQUENTIAL_WRITES, &update_handle);
                    if (err != ESP_OK)
                    {
                        ESP_LOGE(TAG, "esp_ota_begin failed (%s)", esp_err_to_name(err));
                        http_cleanup(client);
                        esp_ota_abort(update_handle);
                        task_fatal_error();
                    }
                    ESP_LOGI(TAG, "esp_ota_begin succeeded");
                }
                else
                {
                    int head = sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t) + sizeof(esp_app_desc_t);
                    ESP_LOGE(TAG, "received package is not fit len %d , %d", data_read, head);
                    http_cleanup(client);
                    esp_ota_abort(update_handle);
                    task_fatal_error();
                }
            }
            err = esp_ota_write(update_handle, (const void *)ota_write_data, data_read);
            if (err != ESP_OK)
            {
                http_cleanup(client);
                esp_ota_abort(update_handle);
                task_fatal_error();
            }
            binary_file_length += data_read;
            ESP_LOGI(TAG, "Written image length %d", binary_file_length);
        }
        else if (data_read == 0)
        {
            /*
             * As esp_http_client_read never returns negative error code, we rely on
             * `errno` to check for underlying transport connectivity closure if any
             */
            if (errno == ECONNRESET || errno == ENOTCONN)
            {
                ESP_LOGE(TAG, "Connection closed, errno = %d", errno);
                break;
            }
            if (esp_http_client_is_complete_data_received(client) == true)
            {
                ESP_LOGI(TAG, "Connection closed");
                break;
            }
        }
    }
    ESP_LOGI(TAG, "Total Write binary data length: %d", binary_file_length);
    if (esp_http_client_is_complete_data_received(client) != true)
    {
        ESP_LOGE(TAG, "Error in receiving complete file");
        http_cleanup(client);
        esp_ota_abort(update_handle);
        task_fatal_error();
    }

    err = esp_ota_end(update_handle);
    if (err != ESP_OK)
    {
        if (err == ESP_ERR_OTA_VALIDATE_FAILED)
        {
            ESP_LOGE(TAG, "Image validation failed, image is corrupted");
        }
        else
        {
            ESP_LOGE(TAG, "esp_ota_end failed (%s)!", esp_err_to_name(err));
        }
        http_cleanup(client);
        task_fatal_error();
    }

    err = esp_ota_set_boot_partition(update_partition);
    if (err != ESP_OK)
    {
        ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (%s)!", esp_err_to_name(err));
        http_cleanup(client);
        task_fatal_error();
    }
    ESP_LOGI(TAG, "Prepare to restart system!");
    esp_restart();
    return;
}

static bool diagnostic(void)
{
    gpio_config_t io_conf;
    io_conf.intr_type = GPIO_INTR_DISABLE;
    io_conf.mode = GPIO_MODE_INPUT;
    io_conf.pin_bit_mask = (1ULL << CONFIG_EXAMPLE_GPIO_DIAGNOSTIC);
    io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE;
    io_conf.pull_up_en = GPIO_PULLUP_ENABLE;
    gpio_config(&io_conf);

    ESP_LOGI(TAG, "Diagnostics (5 sec)...");
    vTaskDelay(5000 / portTICK_PERIOD_MS);

    bool diagnostic_is_ok = gpio_get_level(CONFIG_EXAMPLE_GPIO_DIAGNOSTIC);

    gpio_reset_pin(CONFIG_EXAMPLE_GPIO_DIAGNOSTIC);
    return diagnostic_is_ok;
}

void app_main(void)
{
    ESP_LOGI(TAG, "OTA example app_main start");

    uint8_t sha_256[HASH_LEN] = {0};
    esp_partition_t partition;

    // get sha256 digest for the partition table
    partition.address = ESP_PARTITION_TABLE_OFFSET;
    partition.size = ESP_PARTITION_TABLE_MAX_LEN;
    partition.type = ESP_PARTITION_TYPE_DATA;
    esp_partition_get_sha256(&partition, sha_256);
    print_sha256(sha_256, "SHA-256 for the partition table: ");

    // get sha256 digest for bootloader
    partition.address = ESP_BOOTLOADER_OFFSET;
    partition.size = ESP_PARTITION_TABLE_OFFSET;
    partition.type = ESP_PARTITION_TYPE_APP;
    esp_partition_get_sha256(&partition, sha_256);
    print_sha256(sha_256, "SHA-256 for bootloader: ");

    // get sha256 digest for running partition
    esp_partition_get_sha256(esp_ota_get_running_partition(), sha_256);
    print_sha256(sha_256, "SHA-256 for current firmware: ");

    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)
    {
        // 检查OTA状态
        if (ota_state == ESP_OTA_IMG_PENDING_VERIFY)
        {
            // run diagnostic function ...
            bool diagnostic_is_ok = diagnostic();
            if (diagnostic_is_ok)
            {
                ESP_LOGI(TAG, "Diagnostics completed successfully! Continuing execution ...");
                esp_ota_mark_app_valid_cancel_rollback();
            }
            else
            {
                ESP_LOGE(TAG, "Diagnostics failed! Start rollback to the previous version ...");
                esp_ota_mark_app_invalid_rollback_and_reboot();
            }
        }
    }

    // Initialize NVS.
    esp_err_t err = nvs_flash_init();
    if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND)
    {
        // OTA app partition table has a smaller NVS partition size than the non-OTA
        // partition table. This size mismatch may cause NVS initialization to fail.
        // If this happens, we erase NVS partition and initialize NVS again.
        ESP_ERROR_CHECK(nvs_flash_erase());
        err = nvs_flash_init();
    }
    ESP_ERROR_CHECK(err);

    ESP_ERROR_CHECK(esp_netif_init());
    ESP_ERROR_CHECK(esp_event_loop_create_default());

    /* This helper function configures Wi-Fi or Ethernet, as selected in menuconfig.
     * Read "Establishing Wi-Fi or Ethernet Connection" section in
     * examples/protocols/README.md for more information about this function.
     */
    ESP_ERROR_CHECK(example_connect());

#if CONFIG_EXAMPLE_CONNECT_WIFI
    /* Ensure to disable any WiFi power save mode, this allows best throughput
     * and hence timings for overall OTA operation.
     */
    ESP_LOGI(TAG, "Disable WiFi power save");
    esp_wifi_set_ps(WIFI_PS_NONE);
#endif // CONFIG_EXAMPLE_CONNECT_WIFI

    xTaskCreate(&ota_example_task, "ota_example_task", 8192, NULL, 5, NULL);
}

三、node服务器

1. 代码解析

├── ota_test
		├── certs
		│   ├── server_cert.key
		│   └── server_cert.pem
		└── ota_files
		    ├── ota_1.bin
		    └── ota_2.bin
  • 创建一个Express应用实例
function startOtaServer(config: ServerConfig): https.Server {
  const app = createApp(config);

  // 创建HTTPS服务器
  const server = https.createServer({
    // 读取SSL密钥文件
    key: fs.readFileSync(config.keyFile),
    // 读取SSL证书文件
    cert: fs.readFileSync(config.certFile)
  }, app);

  // 启动服务器监听指定端口
  server.listen(config.port, '0.0.0.0', () => {
    // 打印服务器启动信息,包括本地IP和端口
    console.log(`OTA Server running on https://${getLocalIp()}:${config.port}`);
    // 打印固件文件服务目录
    console.log(`Serving firmware from: ${config.firmwareDir}`);
  });

  // 返回创建的服务器实例
  return server;
}

const config: ServerConfig = {
  port: 8002,
  firmwareDir: './ota_test/ota_files', // 固件的位置
  certFile: './ota_test/certs/server_cert.pem', // 公钥文件
  keyFile: './ota_test/certs/server_cert.key' // 私钥文件
};
  • 固件下载的API
app.get('/firmware/:filename', (req, res) => {
  // 从URL参数获取请求的文件名
  const filename = req.params.filename;
  // 拼接完整的固件文件路径
  const filePath = path.join(PROJECT_PATH, config.firmwareDir, filename);

  // 检查文件是否存在
  if (!fs.existsSync(filePath)) {
    return res.status(404).send('Firmware not found');
  }

  // 获取文件信息
  const stat = fs.statSync(filePath);
  const fileSize = stat.size;

  // 设置响应头
  res.setHeader('Content-Length', fileSize); // 文件大小
  res.setHeader('Transfer-Encoding', 'identity'); // 关键修复:禁用分块传输
  res.setHeader('Content-Type', 'application/octet-stream'); // 二进制流类型

  // 创建文件读取流
  const fileStream = fs.createReadStream(filePath);

  // 文件流打开事件
  fileStream.on('open', () => {
    // 将文件流管道传输到响应对象
    fileStream.pipe(res);
  });

  // 文件流错误处理
  fileStream.on('error', (err) => {
    console.error(`文件流错误: ${err.message}`);
    if (!res.headersSent) {
      // 如果响应头还未发送,返回500错误
      res.status(500).send('文件流错误');
    } else {
      // 如果响应头已发送,直接销毁响应
      res.destroy();
    }
  });
});

2. 全部代码

import express from 'express';
import https from 'https';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { PROJECT_PATH } from './config';

// 配置类型
interface ServerConfig {
  port: number;
  firmwareDir: string;
  certFile: string;
  keyFile: string;
}

// 获取本机IP地址
function getLocalIp(): string {
  const interfaces = os.networkInterfaces();
  for (const name of Object.keys(interfaces)) {
    for (const iface of interfaces[name]!) {
      if (iface.family === 'IPv4' && !iface.internal) {
        return iface.address;
      }
    }
  }
  return 'localhost';
}

// 创建并配置Express应用
function createApp(config: ServerConfig): express.Application {
  const app = express();

  // 修复后的固件下载端点
  app.get('/firmware/:filename', (req, res) => {
    const filename = req.params.filename;
    const filePath = path.join(PROJECT_PATH, config.firmwareDir, filename);

    if (!fs.existsSync(filePath)) {
      return res.status(404).send('Firmware not found');
    }

    const stat = fs.statSync(filePath);
    const fileSize = stat.size;

    res.setHeader('Content-Length', fileSize);
    res.setHeader('Transfer-Encoding', 'identity'); // 关键修复
    res.setHeader('Content-Type', 'application/octet-stream');

    const fileStream = fs.createReadStream(filePath);

    fileStream.on('open', () => {
      fileStream.pipe(res);
    });

    fileStream.on('error', (err) => {
      console.error(`File stream error: ${err.message}`);
      if (!res.headersSent) {
        res.status(500).send('File stream error');
      } else {
        res.destroy();
      }
    });

    res.on('close', () => {
      if (!fileStream.destroyed) {
        fileStream.destroy();
      }
    });
  });

  // 健康检查端点
  app.get('/health', (req, res) => {
    res.status(200).json({
      status: 'active',
      firmwareDir: config.firmwareDir,
      port: config.port
    });
  });

  return app;
}

// 启动HTTPS服务器
function startOtaServer(config: ServerConfig): https.Server {
  const app = createApp(config);

  const server = https.createServer({
    key: fs.readFileSync(config.keyFile),
    cert: fs.readFileSync(config.certFile)
  }, app);

  server.listen(config.port, '0.0.0.0', () => {
    console.log(`OTA Server running on https://${getLocalIp()}:${config.port}`);
    console.log(`Serving firmware from: ${config.firmwareDir}`);
  });

  return server;
}

// 使用示例
const config: ServerConfig = {
  port: 8002,
  firmwareDir: './ota_test/ota_files',
  certFile: './ota_test/certs/server_cert.pem',
  keyFile: './ota_test/certs/server_cert.key'
};

const server = startOtaServer(config);

// 处理退出信号
process.on('SIGINT', () => {
  console.log('Shutting down OTA server...');
  server.close(() => {
    process.exit();
  });
});

四、python服务器

  • 创建python独立环境
# 创建环境(Python 3.3+ 自带)
python -m venv test_env  

# 激活环境
source test_env/bin/activate  

# vscode
ctrl+shift+p 输入 python interpreter 选择test_env

在这里插入图片描述

  • 下面是官方提供的python例子,运行方式如下:
    pytest pytest_native_ota.py
    在这里插入图片描述
import http.server
import multiprocessing
import os
import random
import socket
import ssl
import struct
import subprocess
from typing import Callable
from typing import Tuple

import pexpect
import pytest
# from common_test_methods import get_host_ip4_by_dest_ip
from pytest_embedded import Dut

def get_host_ip4_by_dest_ip(dest_ip: str) -> str:
    """
    通过尝试连接目标IP,自动选择正确的本地IP。
    参数:
        dest_ip: 目标IP地址
    返回:
        本地主机的IPv4地址
    """
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
        s.connect((dest_ip, 80))  # 80是任意端口,无需实际通信
        return s.getsockname()[0]  # 获取本地绑定的IP地址
    
# 硬编码的测试用SSL证书(PEM格式)
server_cert = '-----BEGIN CERTIFICATE-----\n' \
              'MIIDWDCCAkACCQCbF4+gVh/MLjANBgkqhkiG9w0BAQsFADBuMQswCQYDVQQGEwJJ\n'\
              # ... 证书内容省略 ...
              '-----END CERTIFICATE-----\n'

# 硬编码的测试用SSL私钥(PEM格式) 
server_key = '-----BEGIN PRIVATE KEY-----\n'\
             'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDhxF/y7bygndxP\n'\
             # ... 私钥内容省略 ...
             '-----END PRIVATE KEY-----\n'


def create_file(server_file: str, file_data: str) -> None:
    """
    创建文件并写入内容
    参数:
        server_file: 文件路径
        file_data: 要写入的内容
    """
    with open(server_file, 'w+') as file:
        file.write(file_data)


def get_ca_cert(ota_image_dir: str) -> Tuple[str, str]:
    """
    生成SSL证书和密钥文件
    参数:
        ota_image_dir: OTA镜像目录路径
    返回:
        元组(证书文件路径, 密钥文件路径)
    """
    os.chdir(ota_image_dir)  # 切换到OTA镜像目录
    server_file = os.path.join(ota_image_dir, 'server_cert.pem')
    create_file(server_file, server_cert)  # 创建证书文件

    key_file = os.path.join(ota_image_dir, 'server_key.pem')
    create_file(key_file, server_key)  # 创建密钥文件
    return server_file, key_file


def https_request_handler() -> Callable[...,http.server.BaseHTTPRequestHandler]:
    """
    创建自定义HTTP请求处理器,处理broken pipe异常
    返回:
        自定义的RequestHandler类
    """
    class RequestHandler(http.server.SimpleHTTPRequestHandler):
        def finish(self) -> None:
            """重写finish方法,优雅处理socket错误"""
            try:
                if not self.wfile.closed:
                    self.wfile.flush()
                    self.wfile.close()
            except socket.error:
                pass  # 忽略socket错误
            self.rfile.close()

        def handle(self) -> None:
            """重写handle方法,捕获socket错误"""
            try:
                http.server.BaseHTTPRequestHandler.handle(self)
            except socket.error:
                pass  # 忽略socket错误

    return RequestHandler


def start_https_server(ota_image_dir: str, server_ip: str, server_port: int) -> None:
    """
    启动HTTPS服务器
    参数:
        ota_image_dir: OTA镜像目录
        server_ip: 服务器监听IP
        server_port: 服务器监听端口
    """
    server_file, key_file = get_ca_cert(ota_image_dir)  # 获取证书和密钥
    requestHandler = https_request_handler()  # 创建请求处理器
    
    # 创建HTTP服务器
    httpd = http.server.HTTPServer((server_ip, server_port), requestHandler)

    # 配置SSL上下文
    ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    ssl_context.load_cert_chain(certfile=server_file, keyfile=key_file)

    # 包装socket为SSL socket
    httpd.socket = ssl_context.wrap_socket(httpd.socket, server_side=True)
    httpd.serve_forever()  # 启动服务器


def start_chunked_server(ota_image_dir: str, server_port: int) -> subprocess.Popen:
    """
    启动分块传输的HTTPS服务器(使用openssl s_server)
    参数:
        ota_image_dir: OTA镜像目录
        server_port: 服务器端口
    返回:
        subprocess.Popen对象
    """
    server_file, key_file = get_ca_cert(ota_image_dir)
    # 使用openssl命令启动服务器
    chunked_server = subprocess.Popen(['openssl', 's_server', '-WWW', '-key', key_file, 
                                     '-cert', server_file, '-port', str(server_port)])
    return chunked_server


@pytest.mark.esp32
@pytest.mark.ethernet_ota
def test_examples_protocol_native_ota_example(dut: Dut) -> None:
    """
    OTA示例测试用例 - 验证通过HTTPS多次下载完整固件
    测试步骤:
      1. 连接AP/以太网
      2. 通过HTTPS获取OTA镜像
      3. 使用新OTA镜像重启
    参数:
        dut: 被测设备对象
    """
    server_port = 8002  # 服务器端口
    iterations = 3  # 测试迭代次数
    bin_name = 'ota_1.bin'  # 要下载的固件文件名
    
    # 启动HTTPS服务器(使用多进程)
    thread1 = multiprocessing.Process(target=start_https_server, 
                                    args=(dut.app.binary_path, '0.0.0.0', server_port))
    thread1.daemon = True  # 设置为守护进程
    thread1.start()
    
    try:
        # 开始测试迭代
        for _ in range(iterations):
            # 等待设备启动完成
            dut.expect('Loaded app from partition at offset', timeout=30)
            
            try:
                # 获取设备IP地址
                ip_address = dut.expect(r'IPv4 address: (\d+\.\d+\.\d+\.\d+)[^\d]', timeout=30)[1].decode()
                print('Connected to AP/Ethernet with IP: {}'.format(ip_address))
            except pexpect.exceptions.TIMEOUT:
                raise ValueError('ENV_TEST_FAILURE: Cannot connect to AP/Ethernet')
            
            # 获取主机IP
            host_ip = get_host_ip4_by_dest_ip(ip_address)

            # 等待OTA任务启动
            dut.expect('Starting OTA example task', timeout=30)
            
            # 构造OTA URL并发送给设备
            ota_url = 'https://' + host_ip + ':' + str(server_port) + '/firmware/' + bin_name
            print('writing to device: {}'.format(ota_url))
            dut.write(ota_url)
            
            # 等待设备准备重启
            dut.expect('Prepare to restart system!', timeout=60)
    finally:
        # 测试结束,终止服务器进程
        thread1.terminate()

五、升级Log

  • 下面是使用node服务器远程升级成功的LOG

在这里插入图片描述
在这里插入图片描述

六、疑问

1. 网页https请求也不需要pem文件,为什么esp的OTA升级需要pem文件呢?

场景 普通浏览器/系统 ESP设备
证书验证方式 内置信任的CA根证书库 无内置证书库(除非特别配置)
证书来源 操作系统或浏览器预装数百个CA根证书 必须手动提供可信证书
自签名证书支持 会显示警告但可跳过 严格验证,无法跳过
  • 为什么ESP需要PEM文件?

    • 身份验证:防止"中间人攻击"
    • PEM文件包含服务器的公钥证书(或签发它的CA证书)
    • ESP用其验证服务器的HTTPS证书是否由可信机构签发
  • 证书文件的作用

    • server.crt / server.pem:服务器公钥证书:包含服务器身份信息和公钥
    • server.key:服务器私钥:永远不共享(仅服务器持有)
    • ca.pem:证书颁发机构(CA)的根证书:(验证服务器证书是否可信)
  • 为什么普通用户不需要?比如:

    • 访问https://google.com时:
    • 浏览器检查Google证书的签发链
    • 发现是由GlobalSign或Google Trust Services签发
    • 系统已预装这些CA根证书,自动完成验证
服务器证书类型 ESP设备处理方式
公共CA签发 启用证书包功能,无需额外PEM文件
自签名证书 必须提供PEM文件(如文档示例)
私有CA签发 提供私有CA的根证书(ca.pem)

2. ESP32 HTTPS服务器CA证书配置指南

  • 关键结论:ESP需要PEM文件是因为它没有预装可信CA库,必须通过显式提供证书来建立信任关系。这是嵌入式设备安全通信的必要保障,不同于桌面系统的开箱即用特性。

  • 嵌入式设备因资源限制,需手动配置CA证书建立TLS信任链。与桌面系统不同,ESP32需要显式提供证书实现安全通信。

  1. 启用证书包功能:
idf.py menuconfig
→ Component config → mbedTLS → Certificate Bundle → Enable
  1. 代码中移除cert_pem参数,改用:
.crt_bundle_attach = esp_crt_bundle_attach,
  1. 配置 esp_http_client_config_t 参数:
extern const uint8_t server_cert_pem_start[] asm("_binary_ca_cert_pem_start");
extern const uint8_t server_cert_pem_end[] asm("_binary_ca_cert_pem_end");

// 初始化HTTPS客户端配置
esp_http_client_config_t config = {
    .url = CONFIG_EXAMPLE_FIRMWARE_UPG_URL,
    .cert_pem = (char *)server_cert_pem_start,
    .timeout_ms = CONFIG_EXAMPLE_OTA_RECV_TIMEOUT,
    .keep_alive_enable = true,
};
  1. cmakelist.txt 配置:
# Embed the server root certificate into the final binary
idf_build_get_property(project_dir PROJECT_DIR)
idf_component_register(SRCS "native_ota_example.c"
                    INCLUDE_DIRS "."
                    EMBED_TXTFILES ${project_dir}/server_certs/ca_cert.pem)

网站公告

今日签到

点亮在社区的每一天
去签到