一、OTA兩種方式:app_update
與 esp_https_ota
區別
-
ESP32/ESP32-S2/ESP32-C3等可通過Wi-Fi或以太網下載新固件到OTA分區實現運行時升級。ESP-IDF提供兩種OTA升級方法:
- 使用
app_update
組件的原生API - 使用
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獲取流數據時,每次讀取
BUFFSIZE
1024個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_CHECKif (memcmp(new_app_info.version, running_app_info.version, sizeof(new_app_info.version)) == 0) {ESP_LOGW(TAG, "新版本與當前運行版本相同,終止升級");http_cleanup(client);infinite_loop();}
#endifimage_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 exampleThis example code is in the Public Domain (or CC0 licensed, at your option.)Unless required by applicable law or agreed to in writing, thissoftware is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES ORCONDITIONS 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 256static 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_STDINchar url_buf[OTA_URL_SIZE];// 如果配置的URL為FROM_STDIN,則從標準輸入讀取URLif (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_CHECKconfig.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 downloadingmemcpy(&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 partitionif (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_CHECKif (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();}
#endifimage_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 tablepartition.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 bootloaderpartition.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 partitionesp_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_WIFIxTaskCreate(&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 Tupleimport pexpect
import pytest
# from common_test_methods import get_host_ip4_by_dest_ip
from pytest_embedded import Dutdef 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_filedef 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 RequestHandlerdef start_https_server(ota_image_dir: str, server_ip: str, server_port: int) -> None:"""啟動HTTPS服務器參數:ota_image_dir: OTA鏡像目錄server_ip: 服務器監聽IPserver_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 sockethttpd.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')# 獲取主機IPhost_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_nameprint('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需要顯式提供證書實現安全通信。
- 啟用證書包功能:
idf.py menuconfig
→ Component config → mbedTLS → Certificate Bundle → Enable
- 代碼中移除cert_pem參數,改用:
.crt_bundle_attach = esp_crt_bundle_attach,
- 配置 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,
};
- 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)