浏览代码

为esp32s3_espidf代码更新增加OTA升级功能

corvin_zhang 1 月之前
父节点
当前提交
fa6c642c87

+ 7 - 0
.gitignore

@@ -14,3 +14,10 @@ __pycache__/
 /guguji_rl/.venv/
 /guguji_rl/outputs/
 /guguji_rl/.pytest_cache/
+
+# ESP32-S3 ESP-IDF 编译产物与本机配置
+/guguji_real_robot/firmware/esp32s3_espidf/build/
+/guguji_real_robot/firmware/esp32s3_espidf/managed_components/
+/guguji_real_robot/firmware/esp32s3_espidf/sdkconfig
+/guguji_real_robot/firmware/esp32s3_espidf/sdkconfig.old
+/guguji_real_robot/firmware/esp32s3_espidf/.vscode/

+ 6 - 0
.vscode/settings.json

@@ -0,0 +1,6 @@
+{
+  "idf.customExtraVars": {}
+}
+
+    "idf.currentSetup": "/home/corvin/.espressif/v6.0.1/esp-idf"
+}

+ 114 - 0
guguji_real_robot/docs/ota_upgrade_guide.md

@@ -0,0 +1,114 @@
+# ESP32-S3 OTA 升级教程
+
+本工程使用 ESP32-S3 提供的局域网 HTTP 接口接收固件。固件写入当前运行分区之外的 OTA 槽,完整校验通过后才切换启动分区。
+
+## 安全规则
+
+- OTA 前必须让机器人悬空或可靠支撑,并断开关节主动力电源。
+- 机器人处于 `ARMED` 时固件会拒绝升级,必须先停止主机策略并失能电机。
+- OTA 期间不要关闭 ESP32-S3 电源。即使传输中断,当前运行分区也不会被覆盖。
+- HTTP 令牌不能加密局域网流量,请只在机器人热点或可信局域网内使用,不要把 8080 端口暴露到公网。
+
+## 分区布局
+
+16MB Flash 使用以下布局:
+
+| 分区 | 大小 | 用途 |
+| --- | ---: | --- |
+| `nvs` | 24KB | WiFi 和系统参数 |
+| `otadata` | 8KB | OTA 启动状态 |
+| `phy_init` | 4KB | 射频校准数据 |
+| `ota_0` | 4MB | 应用槽 A |
+| `ota_1` | 4MB | 应用槽 B |
+| `storage` | 8064KB | 后续标定和运行数据 |
+
+## 第一次启用 OTA
+
+旧固件使用单应用分区,无法直接无线迁移到双 OTA 分区。第一次必须使用 USB 完整烧录一次。
+
+1. 修改 `firmware/esp32s3_espidf/main/config.h` 中的令牌:
+
+```c
+#define GUGUJI_OTA_AUTH_TOKEN "请替换为自己生成的长随机令牌"
+```
+
+2. 打开 ESP-IDF 6.0.1 终端并执行:
+
+```bash
+cd /home/corvin/Project/guguji_simulation/guguji_real_robot/firmware/esp32s3_espidf
+idf.py set-target esp32s3
+idf.py build
+idf.py -p /dev/ttyACM0 erase-flash flash monitor
+```
+
+这一步会清除旧 NVS 数据和旧分区。以后升级只需 WiFi,不再需要擦除 Flash。
+
+启动日志应包含:
+
+```text
+OTA 服务已启动: port=8080, version=..., partition=ota_0
+```
+
+## 日常 OTA 升级
+
+1. 修改代码后在电脑上编译新固件:
+
+```bash
+cd /home/corvin/Project/guguji_simulation/guguji_real_robot/firmware/esp32s3_espidf
+idf.py build
+```
+
+OTA 只上传应用镜像:
+
+```text
+build/guguji_esp32s3_bridge.bin
+```
+
+不要上传 `bootloader.bin`、`partition-table.bin` 或 `.elf` 文件。
+
+2. 连接机器人 WiFi 热点 `guguji-robot`。AP 模式下 ESP32-S3 默认地址为 `192.168.4.1`。
+
+3. 先查询当前版本:
+
+```bash
+cd /home/corvin/Project/guguji_simulation/guguji_real_robot
+export GUGUJI_OTA_TOKEN='与 config.h 完全相同的令牌'
+python3 host/scripts/ota_update.py --status-only
+```
+
+4. 确认机器人已失能,然后上传:
+
+```bash
+python3 host/scripts/ota_update.py \
+  --firmware firmware/esp32s3_espidf/build/guguji_esp32s3_bridge.bin
+```
+
+脚本会显示上传进度,等待 ESP32-S3 重启,并确认运行分区从 `ota_0` 切换到 `ota_1`,下次升级再切回 `ota_0`。
+
+STA 模式下使用路由器分配的地址:
+
+```bash
+python3 host/scripts/ota_update.py \
+  --host 192.168.1.123 \
+  --firmware firmware/esp32s3_espidf/build/guguji_esp32s3_bridge.bin
+```
+
+## LED 和蜂鸣器提示
+
+- 蓝白快速闪烁、三声升调:正在接收和写入 OTA。
+- 橙色快速闪烁、两声降调:OTA 失败,提示持续约 10 秒。
+- OTA 成功后设备自动重启,随后恢复正常启动提示。
+
+## 自动回滚
+
+新镜像第一次启动时处于待确认状态。只有 WiFi、传感器、CAN、状态输出和 OTA 服务都成功初始化后,固件才会确认新镜像。
+
+如果新版本在确认前崩溃、触发看门狗或断电,下一次启动会自动回到上一版本。已经完成启动确认后出现的运行期错误不会自动回滚。
+
+## 常见错误
+
+- `HTTP 401`:上传令牌和 `GUGUJI_OTA_AUTH_TOKEN` 不一致。
+- `HTTP 409: disarm the robot before OTA`:机器人仍处于使能状态,先停止主机策略并发送失能命令。
+- `HTTP 413`:应用镜像超过 4MB,或上传了错误文件。
+- 上传后无法重连:检查电脑是否仍连接 `guguji-robot`,必要时查看 USB 串口日志。
+- 新版本自动回到旧版本:新固件未通过启动初始化,串口日志会显示失败模块。

+ 3 - 0
guguji_real_robot/firmware/esp32s3_espidf/CMakeLists.txt

@@ -1,4 +1,7 @@
 cmake_minimum_required(VERSION 3.16)
 
+# 本工程只面向 ESP32-S3,固定目标可避免重新生成 sdkconfig 后误选为 ESP32。
+set(IDF_TARGET "esp32s3" CACHE STRING "ESP-IDF target")
+
 include($ENV{IDF_PATH}/tools/cmake/project.cmake)
 project(guguji_esp32s3_bridge)

+ 30 - 0
guguji_real_robot/firmware/esp32s3_espidf/README.md

@@ -2,6 +2,14 @@
 
 这是 guguji 真实机器人桥接固件,当前要求 ESP-IDF `>=6.0.1,<6.1.0`。
 
+固定硬件配置为 ESP32-S3 N16R8:
+
+- 16MB Quad Flash,QIO,80MHz
+- 8MB Octal PSRAM,80MHz
+- PSRAM 启动自检,并加入标准 `malloc()` 内存池
+
+上述参数保存在 `sdkconfig.defaults`,删除并重新生成 `sdkconfig` 后仍会自动生效。
+
 底层驱动已经迁移到 ESP-IDF 6 新接口:
 
 - I2C:`driver/i2c_master.h` 句柄式 master bus/device API
@@ -19,6 +27,7 @@
 - GPIO42:无源蜂鸣器状态提示
 - GPIO45:WS2812 RGB LED 状态提示
 - 20ms 控制周期,200ms 命令超时停止电机
+- 双分区 OTA:HTTP 8080 推送升级,支持启动失败自动回滚
 
 ## VSCode 配置
 
@@ -68,6 +77,25 @@ idf.py -p /dev/ttyUSB0 flash monitor
 
 如果开发板枚举为 `/dev/ttyACM0`,把端口名称替换掉。串口监视器使用 `Ctrl+]` 退出。
 
+启动日志中应看到类似下面的容量信息,用来确认实际硬件与固件配置一致:
+
+```text
+硬件内存: Flash=16MB, PSRAM=8MB, PSRAM可用=xxxxKB
+```
+
+## OTA 升级
+
+第一次启用 OTA 需要通过 USB 烧录新的双分区表。以后在 WiFi 下运行:
+
+```bash
+cd /home/corvin/Project/guguji_simulation/guguji_real_robot
+export GUGUJI_OTA_TOKEN='与 main/config.h 相同的令牌'
+python3 host/scripts/ota_update.py \
+  --firmware firmware/esp32s3_espidf/build/guguji_esp32s3_bridge.bin
+```
+
+完整步骤、安全要求和回滚说明见 [`docs/ota_upgrade_guide.md`](../../docs/ota_upgrade_guide.md)。
+
 请先修改 `main/config.h` 中的引脚,再修改 `main/robot_config.h` 中的 CAN ID、方向和零偏。
 
 状态提示:
@@ -78,3 +106,5 @@ idf.py -p /dev/ttyUSB0 flash monitor
 - 黄灯双闪:命令超时
 - 红灯快闪:急停或电机故障
 - 紫灯慢闪:传感器离线或反馈超时警告
+- 蓝白快闪:OTA 正在升级
+- 橙灯快闪:OTA 升级失败

+ 10 - 0
guguji_real_robot/firmware/esp32s3_espidf/dependencies.lock

@@ -0,0 +1,10 @@
+dependencies:
+  idf:
+    source:
+      type: idf
+    version: 6.0.1
+direct_dependencies:
+- idf
+manifest_hash: c6e694a9f81f5d6f7f5ea4dadd067b3007904a4aa2ef246e0d9315c8ccde3aa5
+target: esp32s3
+version: 3.0.0

+ 8 - 0
guguji_real_robot/firmware/esp32s3_espidf/main/CMakeLists.txt

@@ -2,21 +2,29 @@ idf_component_register(
     SRCS
         "app_main.c"
         "guguji_protocol.c"
+        "ota_update.c"
         "rs00_mit.c"
         "sensors.c"
         "status_outputs.c"
     INCLUDE_DIRS "."
     REQUIRES
+        app_update
+        esp_app_format
         esp_driver_gpio
         esp_driver_i2c
         esp_driver_ledc
         esp_driver_rmt
         esp_driver_twai
         esp_event
+        esp_http_server
+        esp_psram
         esp_netif
         esp_timer
         esp_wifi
+        esp_system
         freertos
+        heap
         lwip
         nvs_flash
+        spi_flash
 )

+ 46 - 1
guguji_real_robot/firmware/esp32s3_espidf/main/app_main.c

@@ -5,8 +5,11 @@
 
 #include "config.h"
 #include "esp_event.h"
+#include "esp_flash.h"
+#include "esp_heap_caps.h"
 #include "esp_log.h"
 #include "esp_netif.h"
+#include "esp_psram.h"
 #include "esp_timer.h"
 #include "esp_wifi.h"
 #include "freertos/FreeRTOS.h"
@@ -18,6 +21,7 @@
 #include "nvs_flash.h"
 
 #include "guguji_protocol.h"
+#include "ota_update.h"
 #include "robot_config.h"
 #include "rs00_mit.h"
 #include "sensors.h"
@@ -94,6 +98,23 @@ static void stop_all_motors(void)
     }
 }
 
+static bool prepare_for_ota(void *context)
+{
+    (void)context;
+    if (g_robot_state == GUGUJI_STATE_ARMED) {
+        return false;
+    }
+
+    // 清除旧命令并再次发送停止帧,保证写 Flash 期间关节保持失能。
+    xSemaphoreTake(g_command_mutex, portMAX_DELAY);
+    g_have_command = false;
+    g_last_command_ms = 0;
+    xSemaphoreGive(g_command_mutex);
+    stop_all_motors();
+    g_robot_state = GUGUJI_STATE_DISARMED;
+    return true;
+}
+
 static void clear_all_motor_faults(void)
 {
     for (int i = 0; i < GUGUJI_JOINT_COUNT; ++i) {
@@ -291,6 +312,16 @@ static void control_task(void *arg)
     uint32_t last_clear_fault_sequence = UINT32_MAX;
 
     while (true) {
+        if (guguji_ota_is_active()) {
+            if (motors_enabled) {
+                stop_all_motors();
+                motors_enabled = false;
+            }
+            g_robot_state = GUGUJI_STATE_DISARMED;
+            vTaskDelay(pdMS_TO_TICKS(GUGUJI_CONTROL_PERIOD_MS));
+            continue;
+        }
+
         guguji_command_packet_t command = {0};
         bool have_command = false;
         uint32_t command_age_ms = UINT32_MAX;
@@ -460,8 +491,11 @@ static void status_task(void *arg)
             GUGUJI_FAULT_IMU_OFFLINE |
             GUGUJI_FAULT_MAG_OFFLINE);
         guguji_status_mode_t mode = GUGUJI_STATUS_DISARMED;
+        const guguji_ota_state_t ota_state = guguji_ota_get_state();
 
-        if (motor_faults != 0) {
+        if (ota_state == GUGUJI_OTA_STATE_RECEIVING) {
+            mode = GUGUJI_STATUS_OTA_UPDATING;
+        } else if (motor_faults != 0) {
             mode = GUGUJI_STATUS_FAULT;
         } else if (g_robot_state == GUGUJI_STATE_ESTOP) {
             mode = GUGUJI_STATUS_ESTOP;
@@ -471,6 +505,8 @@ static void status_task(void *arg)
             mode = GUGUJI_STATUS_WAITING_HOST;
         } else if (g_robot_state == GUGUJI_STATE_ARMED) {
             mode = GUGUJI_STATUS_ARMED;
+        } else if (ota_state == GUGUJI_OTA_STATE_RECENT_ERROR) {
+            mode = GUGUJI_STATUS_OTA_ERROR;
         } else if (warning_faults != 0) {
             mode = GUGUJI_STATUS_SENSOR_WARN;
         } else {
@@ -484,6 +520,13 @@ static void status_task(void *arg)
 
 void app_main(void)
 {
+    uint32_t flash_size = 0;
+    ESP_ERROR_CHECK(esp_flash_get_physical_size(esp_flash_default_chip, &flash_size));
+    ESP_LOGI(TAG, "硬件内存: Flash=%" PRIu32 "MB, PSRAM=%uMB, PSRAM可用=%uKB",
+             flash_size / (1024U * 1024U),
+             (unsigned)(esp_psram_get_size() / (1024U * 1024U)),
+             (unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024U));
+
     esp_err_t err = nvs_flash_init();
     if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
         ESP_ERROR_CHECK(nvs_flash_erase());
@@ -511,5 +554,7 @@ void app_main(void)
     xTaskCreatePinnedToCore(telemetry_task, "telemetry", 4096, NULL, 5, NULL, 0);
     xTaskCreatePinnedToCore(status_task, "status", 4096, NULL, 4, NULL, 0);
 
+    ESP_ERROR_CHECK(guguji_ota_server_start(prepare_for_ota, NULL));
+    ESP_ERROR_CHECK(guguji_ota_confirm_running_image());
     ESP_LOGI(TAG, "guguji ESP32S3 real-robot bridge started");
 }

+ 4 - 0
guguji_real_robot/firmware/esp32s3_espidf/main/config.h

@@ -14,6 +14,10 @@
 #define GUGUJI_WIFI_STA_PASSWORD "your-router-password"
 
 #define GUGUJI_UDP_COMMAND_PORT 7777
+
+// OTA 使用局域网 HTTP 推送。量产或接入公共网络前务必替换此令牌。
+#define GUGUJI_OTA_HTTP_PORT 8080
+#define GUGUJI_OTA_AUTH_TOKEN "change-this-guguji-ota-token"
 // ==============================
 // ESP32S3 引脚配置
 // ==============================

+ 301 - 0
guguji_real_robot/firmware/esp32s3_espidf/main/ota_update.c

@@ -0,0 +1,301 @@
+#include "ota_update.h"
+
+#include <inttypes.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "config.h"
+#include "esp_app_desc.h"
+#include "esp_check.h"
+#include "esp_http_server.h"
+#include "esp_log.h"
+#include "esp_ota_ops.h"
+#include "esp_system.h"
+#include "esp_timer.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+
+#define OTA_RECEIVE_BUFFER_SIZE 4096
+#define OTA_ERROR_VISIBLE_US (10LL * 1000LL * 1000LL)
+#define OTA_TOKEN_HEADER "X-Guguji-OTA-Token"
+
+static const char *TAG = "ota_update";
+
+static httpd_handle_t g_http_server = NULL;
+static guguji_ota_prepare_callback_t g_prepare_callback = NULL;
+static void *g_callback_context = NULL;
+static volatile guguji_ota_state_t g_ota_state = GUGUJI_OTA_STATE_IDLE;
+static volatile int64_t g_error_visible_until_us = 0;
+
+_Static_assert(sizeof(GUGUJI_OTA_AUTH_TOKEN) <= 96, "OTA token must not exceed 95 characters");
+
+static bool constant_time_token_equal(const char *received, const char *expected)
+{
+    const size_t received_len = strlen(received);
+    const size_t expected_len = strlen(expected);
+    unsigned char difference = (unsigned char)(received_len ^ expected_len);
+    const size_t compare_len = received_len > expected_len ? received_len : expected_len;
+
+    for (size_t i = 0; i < compare_len; ++i) {
+        const unsigned char left = i < received_len ? (unsigned char)received[i] : 0;
+        const unsigned char right = i < expected_len ? (unsigned char)expected[i] : 0;
+        difference |= left ^ right;
+    }
+    return difference == 0;
+}
+
+static bool request_is_authorized(httpd_req_t *request)
+{
+    const size_t token_len = httpd_req_get_hdr_value_len(request, OTA_TOKEN_HEADER);
+    if (token_len == 0 || token_len >= 96) {
+        return false;
+    }
+
+    char token[96] = {0};
+    if (httpd_req_get_hdr_value_str(request, OTA_TOKEN_HEADER, token, sizeof(token)) != ESP_OK) {
+        return false;
+    }
+    return constant_time_token_equal(token, GUGUJI_OTA_AUTH_TOKEN);
+}
+
+static esp_err_t send_json(httpd_req_t *request, const char *status, const char *json)
+{
+    httpd_resp_set_status(request, status);
+    httpd_resp_set_type(request, "application/json");
+    httpd_resp_set_hdr(request, "Cache-Control", "no-store");
+    return httpd_resp_sendstr(request, json);
+}
+
+static esp_err_t send_json_error(httpd_req_t *request, const char *status, const char *message)
+{
+    char response[192];
+    snprintf(response, sizeof(response), "{\"ok\":false,\"error\":\"%s\"}", message);
+    return send_json(request, status, response);
+}
+
+static esp_err_t authorize_or_reject(httpd_req_t *request)
+{
+    if (request_is_authorized(request)) {
+        return ESP_OK;
+    }
+    ESP_LOGW(TAG, "拒绝未授权的 OTA 请求");
+    send_json_error(request, "401 Unauthorized", "invalid OTA token");
+    return ESP_ERR_INVALID_STATE;
+}
+
+static esp_err_t ota_status_handler(httpd_req_t *request)
+{
+    if (authorize_or_reject(request) != ESP_OK) {
+        return ESP_OK;
+    }
+
+    const esp_partition_t *running = esp_ota_get_running_partition();
+    const esp_partition_t *next = esp_ota_get_next_update_partition(NULL);
+    const esp_app_desc_t *description = esp_app_get_description();
+    char response[320];
+    snprintf(
+        response,
+        sizeof(response),
+        "{\"ok\":true,\"project\":\"%s\",\"version\":\"%s\","
+        "\"running_partition\":\"%s\",\"next_partition\":\"%s\",\"ota_state\":%d}",
+        description->project_name,
+        description->version,
+        running != NULL ? running->label : "unknown",
+        next != NULL ? next->label : "unavailable",
+        (int)guguji_ota_get_state());
+    return send_json(request, "200 OK", response);
+}
+
+static void record_ota_failure(void)
+{
+    g_error_visible_until_us = esp_timer_get_time() + OTA_ERROR_VISIBLE_US;
+    g_ota_state = GUGUJI_OTA_STATE_RECENT_ERROR;
+}
+
+static esp_err_t ota_update_handler(httpd_req_t *request)
+{
+    if (authorize_or_reject(request) != ESP_OK) {
+        return ESP_OK;
+    }
+    if (guguji_ota_is_active()) {
+        return send_json_error(request, "409 Conflict", "OTA update already in progress");
+    }
+    if (request->content_len == 0) {
+        return send_json_error(request, "400 Bad Request", "firmware body is empty");
+    }
+
+    const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL);
+    if (update_partition == NULL) {
+        return send_json_error(request, "500 Internal Server Error", "no OTA partition available");
+    }
+    if ((size_t)request->content_len > update_partition->size) {
+        return send_json_error(request, "413 Content Too Large", "firmware exceeds OTA partition size");
+    }
+    g_ota_state = GUGUJI_OTA_STATE_RECEIVING;
+    if (g_prepare_callback != NULL && !g_prepare_callback(g_callback_context)) {
+        g_ota_state = GUGUJI_OTA_STATE_IDLE;
+        ESP_LOGW(TAG, "机器人未处于安全失能状态,拒绝 OTA");
+        return send_json_error(request, "409 Conflict", "disarm the robot before OTA");
+    }
+
+    ESP_LOGI(
+        TAG,
+        "开始 OTA: 目标分区=%s, 地址=0x%08" PRIx32 ", 镜像=%zu bytes",
+        update_partition->label,
+        update_partition->address,
+        request->content_len);
+
+    uint8_t *buffer = malloc(OTA_RECEIVE_BUFFER_SIZE);
+    if (buffer == NULL) {
+        record_ota_failure();
+        return send_json_error(request, "500 Internal Server Error", "cannot allocate OTA buffer");
+    }
+
+    esp_ota_handle_t update_handle = 0;
+    bool ota_handle_open = false;
+    esp_err_t result = esp_ota_begin(update_partition, OTA_WITH_SEQUENTIAL_WRITES, &update_handle);
+    if (result == ESP_OK) {
+        ota_handle_open = true;
+    }
+
+    size_t written = 0;
+    unsigned int last_progress = 0;
+    unsigned int timeout_count = 0;
+    while (result == ESP_OK && written < (size_t)request->content_len) {
+        const size_t remaining = (size_t)request->content_len - written;
+        const size_t wanted = remaining < OTA_RECEIVE_BUFFER_SIZE ? remaining : OTA_RECEIVE_BUFFER_SIZE;
+        const int received = httpd_req_recv(request, (char *)buffer, wanted);
+        if (received == HTTPD_SOCK_ERR_TIMEOUT && timeout_count++ < 5) {
+            continue;
+        }
+        if (received <= 0) {
+            ESP_LOGE(TAG, "OTA 上传连接中断: received=%d", received);
+            result = ESP_FAIL;
+            break;
+        }
+        timeout_count = 0;
+        result = esp_ota_write(update_handle, buffer, (size_t)received);
+        if (result != ESP_OK) {
+            ESP_LOGE(TAG, "写入 OTA 分区失败: %s", esp_err_to_name(result));
+            break;
+        }
+
+        written += (size_t)received;
+        const unsigned int progress = (unsigned int)(written * 100U / (size_t)request->content_len);
+        if (progress >= last_progress + 10U || progress == 100U) {
+            ESP_LOGI(TAG, "OTA 接收进度: %u%% (%zu/%zu bytes)", progress, written, request->content_len);
+            last_progress = progress;
+        }
+    }
+    free(buffer);
+
+    if (result == ESP_OK && written != (size_t)request->content_len) {
+        result = ESP_ERR_INVALID_SIZE;
+    }
+    if (result == ESP_OK) {
+        result = esp_ota_end(update_handle);
+        ota_handle_open = false;
+        if (result == ESP_ERR_OTA_VALIDATE_FAILED) {
+            ESP_LOGE(TAG, "OTA 镜像校验失败,镜像损坏或目标芯片不匹配");
+        }
+    }
+    if (result == ESP_OK) {
+        result = esp_ota_set_boot_partition(update_partition);
+    }
+
+    if (result != ESP_OK) {
+        if (ota_handle_open) {
+            esp_ota_abort(update_handle);
+        }
+        ESP_LOGE(TAG, "OTA 失败: %s", esp_err_to_name(result));
+        record_ota_failure();
+        return send_json_error(request, "500 Internal Server Error", esp_err_to_name(result));
+    }
+
+    esp_app_desc_t new_description = {0};
+    if (esp_ota_get_partition_description(update_partition, &new_description) == ESP_OK) {
+        ESP_LOGI(TAG, "OTA 镜像校验通过: version=%s", new_description.version);
+    }
+    ESP_LOGI(TAG, "OTA 完成,1 秒后重启进入分区 %s", update_partition->label);
+    send_json(request, "200 OK", "{\"ok\":true,\"message\":\"update accepted; rebooting\"}");
+    vTaskDelay(pdMS_TO_TICKS(1000));
+    esp_restart();
+    return ESP_OK;
+}
+
+esp_err_t guguji_ota_server_start(
+    guguji_ota_prepare_callback_t prepare_callback,
+    void *callback_context)
+{
+    if (g_http_server != NULL) {
+        return ESP_ERR_INVALID_STATE;
+    }
+
+    g_prepare_callback = prepare_callback;
+    g_callback_context = callback_context;
+
+    httpd_config_t config = HTTPD_DEFAULT_CONFIG();
+    config.server_port = GUGUJI_OTA_HTTP_PORT;
+    config.stack_size = 8192;
+    config.max_open_sockets = 4;
+    config.recv_wait_timeout = 15;
+    config.send_wait_timeout = 10;
+    config.lru_purge_enable = true;
+
+    ESP_RETURN_ON_ERROR(httpd_start(&g_http_server, &config), TAG, "启动 OTA HTTP 服务失败");
+
+    const httpd_uri_t status_uri = {
+        .uri = "/ota/status",
+        .method = HTTP_GET,
+        .handler = ota_status_handler,
+    };
+    const httpd_uri_t update_uri = {
+        .uri = "/ota/update",
+        .method = HTTP_POST,
+        .handler = ota_update_handler,
+    };
+    ESP_RETURN_ON_ERROR(httpd_register_uri_handler(g_http_server, &status_uri), TAG, "注册 OTA 状态接口失败");
+    ESP_RETURN_ON_ERROR(httpd_register_uri_handler(g_http_server, &update_uri), TAG, "注册 OTA 上传接口失败");
+
+    const esp_partition_t *running = esp_ota_get_running_partition();
+    const esp_app_desc_t *description = esp_app_get_description();
+    ESP_LOGI(
+        TAG,
+        "OTA 服务已启动: port=%d, version=%s, partition=%s",
+        GUGUJI_OTA_HTTP_PORT,
+        description->version,
+        running != NULL ? running->label : "unknown");
+    return ESP_OK;
+}
+
+esp_err_t guguji_ota_confirm_running_image(void)
+{
+    const esp_partition_t *running = esp_ota_get_running_partition();
+    if (running == NULL) {
+        return ESP_ERR_NOT_FOUND;
+    }
+
+    esp_ota_img_states_t state;
+    esp_err_t result = esp_ota_get_state_partition(running, &state);
+    if (result == ESP_OK && state == ESP_OTA_IMG_PENDING_VERIFY) {
+        result = esp_ota_mark_app_valid_cancel_rollback();
+        if (result == ESP_OK) {
+            ESP_LOGI(TAG, "新固件运行检查通过,已确认镜像并取消回滚");
+        }
+    }
+    return (result == ESP_ERR_NOT_SUPPORTED || result == ESP_ERR_NOT_FOUND) ? ESP_OK : result;
+}
+
+guguji_ota_state_t guguji_ota_get_state(void)
+{
+    if (g_ota_state == GUGUJI_OTA_STATE_RECENT_ERROR && esp_timer_get_time() >= g_error_visible_until_us) {
+        g_ota_state = GUGUJI_OTA_STATE_IDLE;
+    }
+    return g_ota_state;
+}
+
+bool guguji_ota_is_active(void)
+{
+    return guguji_ota_get_state() == GUGUJI_OTA_STATE_RECEIVING;
+}

+ 24 - 0
guguji_real_robot/firmware/esp32s3_espidf/main/ota_update.h

@@ -0,0 +1,24 @@
+#pragma once
+
+#include <stdbool.h>
+
+#include "esp_err.h"
+
+typedef bool (*guguji_ota_prepare_callback_t)(void *context);
+
+typedef enum {
+    GUGUJI_OTA_STATE_IDLE = 0,
+    GUGUJI_OTA_STATE_RECEIVING,
+    GUGUJI_OTA_STATE_RECENT_ERROR,
+} guguji_ota_state_t;
+
+// 启动局域网 OTA HTTP 服务;prepare_callback 用于执行机器人侧安全检查。
+esp_err_t guguji_ota_server_start(
+    guguji_ota_prepare_callback_t prepare_callback,
+    void *callback_context);
+
+// 新固件完成硬件和网络初始化后调用,用于确认镜像并取消自动回滚。
+esp_err_t guguji_ota_confirm_running_image(void);
+
+guguji_ota_state_t guguji_ota_get_state(void);
+bool guguji_ota_is_active(void);

+ 22 - 0
guguji_real_robot/firmware/esp32s3_espidf/main/status_outputs.c

@@ -44,6 +44,10 @@ static const char *status_name(guguji_status_mode_t mode)
         return "fault";
     case GUGUJI_STATUS_SENSOR_WARN:
         return "sensor_warn";
+    case GUGUJI_STATUS_OTA_UPDATING:
+        return "ota_updating";
+    case GUGUJI_STATUS_OTA_ERROR:
+        return "ota_error";
     default:
         return "unknown";
     }
@@ -89,6 +93,18 @@ static void buzzer_pattern_for_transition(guguji_status_mode_t mode)
         vTaskDelay(pdMS_TO_TICKS(40));
         buzzer_beep(1000, 60);
         break;
+    case GUGUJI_STATUS_OTA_UPDATING:
+        buzzer_beep(1600, 60);
+        vTaskDelay(pdMS_TO_TICKS(40));
+        buzzer_beep(2200, 60);
+        vTaskDelay(pdMS_TO_TICKS(40));
+        buzzer_beep(2800, 80);
+        break;
+    case GUGUJI_STATUS_OTA_ERROR:
+        buzzer_beep(1200, 100);
+        vTaskDelay(pdMS_TO_TICKS(50));
+        buzzer_beep(700, 180);
+        break;
     default:
         break;
     }
@@ -136,6 +152,12 @@ static void led_for_mode(guguji_status_mode_t mode, uint32_t now_ms)
     case GUGUJI_STATUS_SENSOR_WARN:
         ws2812_write_rgb(slow_on ? 28 : 3, 0, slow_on ? 42 : 6);
         break;
+    case GUGUJI_STATUS_OTA_UPDATING:
+        ws2812_write_rgb(fast_on ? 30 : 0, fast_on ? 30 : 6, 55);
+        break;
+    case GUGUJI_STATUS_OTA_ERROR:
+        ws2812_write_rgb(fast_on ? 55 : 8, fast_on ? 18 : 2, 0);
+        break;
     default:
         ws2812_write_rgb(0, 0, 0);
         break;

+ 2 - 0
guguji_real_robot/firmware/esp32s3_espidf/main/status_outputs.h

@@ -13,6 +13,8 @@ typedef enum {
     GUGUJI_STATUS_ESTOP,
     GUGUJI_STATUS_FAULT,
     GUGUJI_STATUS_SENSOR_WARN,
+    GUGUJI_STATUS_OTA_UPDATING,
+    GUGUJI_STATUS_OTA_ERROR,
 } guguji_status_mode_t;
 
 esp_err_t guguji_status_outputs_init(void);

+ 8 - 0
guguji_real_robot/firmware/esp32s3_espidf/partitions.csv

@@ -0,0 +1,8 @@
+# Name,    Type, SubType, Offset,   Size,     Flags
+# 16MB Flash:两个 4MB 应用槽支持 OTA,其余空间留给后续标定和运行数据。
+nvs,        data, nvs,     0x9000,   0x6000,
+otadata,    data, ota,     0xf000,   0x2000,
+phy_init,   data, phy,     0x11000,  0x1000,
+ota_0,      app,  ota_0,   0x20000,  0x400000,
+ota_1,      app,  ota_1,   0x420000, 0x400000,
+storage,    data, spiffs,  0x820000, 0x7e0000,

+ 21 - 0
guguji_real_robot/firmware/esp32s3_espidf/sdkconfig.defaults

@@ -1,3 +1,24 @@
+# 固定硬件为 ESP32-S3 N16R8:16MB Quad Flash + 8MB Octal PSRAM。
+CONFIG_ESPTOOLPY_FLASHMODE_QIO=y
+CONFIG_ESPTOOLPY_FLASHFREQ_80M=y
+CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
+
+# 双 OTA 分区与自动回滚:新固件首次启动未通过初始化时恢复上一版本。
+CONFIG_PARTITION_TABLE_CUSTOM=y
+CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
+CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
+
+# 8MB Octal PSRAM 使用稳定的 80MHz,并加入通用 malloc() 内存池。
+CONFIG_SPIRAM=y
+CONFIG_SPIRAM_MODE_OCT=y
+CONFIG_SPIRAM_TYPE_AUTO=y
+CONFIG_SPIRAM_SPEED_80M=y
+CONFIG_SPIRAM_BOOT_INIT=y
+CONFIG_SPIRAM_USE_MALLOC=y
+CONFIG_SPIRAM_MEMTEST=y
+CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=16384
+CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=32768
+
 CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
 CONFIG_FREERTOS_HZ=1000
 CONFIG_LOG_DEFAULT_LEVEL_INFO=y

+ 3 - 0
guguji_real_robot/host/README.md

@@ -16,3 +16,6 @@ python guguji_real_robot/host/scripts/run_real_policy.py \
 ```
 
 确认机器人悬空、急停和电源都准备好后,再加 `--arm` 让 ESP32S3 使能电机。
+
+固件 OTA 升级工具位于 `scripts/ota_update.py`,详细教程见
+[`../docs/ota_upgrade_guide.md`](../docs/ota_upgrade_guide.md)。

+ 180 - 0
guguji_real_robot/host/scripts/ota_update.py

@@ -0,0 +1,180 @@
+#!/usr/bin/env python3
+"""向 guguji ESP32-S3 推送 OTA 固件。仅使用 Python 标准库。"""
+
+from __future__ import annotations
+
+import argparse
+import http.client
+import json
+import os
+from pathlib import Path
+import sys
+import time
+from typing import Any, BinaryIO
+
+
+class ProgressReader:
+    def __init__(self, file: BinaryIO, total: int) -> None:
+        self._file = file
+        self._total = total
+        self._sent = 0
+        self._last_percent = -1
+
+    def read(self, size: int = -1) -> bytes:
+        chunk = self._file.read(size)
+        self._sent += len(chunk)
+        percent = int(self._sent * 100 / self._total) if self._total else 100
+        if percent != self._last_percent:
+            print(
+                f"\r上传进度: {percent:3d}% ({self._sent}/{self._total} bytes)",
+                end="",
+                flush=True,
+            )
+            self._last_percent = percent
+        if not chunk:
+            print()
+        return chunk
+
+
+def request_json(
+    host: str,
+    port: int,
+    token: str,
+    method: str,
+    path: str,
+    body: Any = None,
+    content_length: int | None = None,
+    timeout: float = 20.0,
+) -> dict[str, Any]:
+    headers = {
+        "Accept": "application/json",
+        "X-Guguji-OTA-Token": token,
+    }
+    if content_length is not None:
+        headers["Content-Type"] = "application/octet-stream"
+        headers["Content-Length"] = str(content_length)
+
+    connection = http.client.HTTPConnection(host, port, timeout=timeout)
+    try:
+        connection.request(method, path, body=body, headers=headers)
+        response = connection.getresponse()
+        response_body = response.read().decode("utf-8", errors="replace")
+    finally:
+        connection.close()
+
+    try:
+        payload = json.loads(response_body) if response_body else {}
+    except json.JSONDecodeError:
+        payload = {"raw_response": response_body}
+    if response.status < 200 or response.status >= 300:
+        error = payload.get("error", response.reason)
+        raise RuntimeError(f"HTTP {response.status}: {error}")
+    return payload
+
+
+def query_status(host: str, port: int, token: str, timeout: float = 10.0) -> dict[str, Any]:
+    return request_json(host, port, token, "GET", "/ota/status", timeout=timeout)
+
+
+def print_status(prefix: str, status: dict[str, Any]) -> None:
+    print(
+        f"{prefix}: version={status.get('version', 'unknown')}, "
+        f"运行分区={status.get('running_partition', 'unknown')}, "
+        f"下次写入={status.get('next_partition', 'unknown')}"
+    )
+
+
+def wait_for_reboot(
+    host: str,
+    port: int,
+    token: str,
+    previous_partition: str,
+    timeout: float,
+) -> dict[str, Any] | None:
+    deadline = time.monotonic() + timeout
+    print("等待 ESP32-S3 重启并重新连接 OTA 服务...")
+    while time.monotonic() < deadline:
+        time.sleep(2.0)
+        try:
+            status = query_status(host, port, token, timeout=3.0)
+        except (OSError, RuntimeError, http.client.HTTPException):
+            continue
+        if status.get("running_partition") != previous_partition:
+            return status
+    return None
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description="guguji ESP32-S3 OTA 升级工具")
+    parser.add_argument("--host", default="192.168.4.1", help="ESP32-S3 IP,AP 模式默认为 192.168.4.1")
+    parser.add_argument("--port", type=int, default=8080, help="OTA HTTP 端口")
+    parser.add_argument(
+        "--token",
+        default=os.environ.get("GUGUJI_OTA_TOKEN"),
+        help="OTA 令牌,也可使用 GUGUJI_OTA_TOKEN 环境变量",
+    )
+    parser.add_argument("--firmware", type=Path, help="待上传的 ESP-IDF 应用 .bin 文件")
+    parser.add_argument("--status-only", action="store_true", help="只查询设备版本和分区")
+    parser.add_argument("--no-wait", action="store_true", help="上传完成后不等待设备重启确认")
+    parser.add_argument("--reboot-timeout", type=float, default=45.0, help="等待重启的秒数")
+    return parser.parse_args()
+
+
+def main() -> int:
+    args = parse_args()
+    if not args.token:
+        print("错误:请通过 --token 或 GUGUJI_OTA_TOKEN 提供 OTA 令牌", file=sys.stderr)
+        return 2
+
+    try:
+        before = query_status(args.host, args.port, args.token)
+        print_status("当前固件", before)
+        if args.status_only:
+            return 0
+
+        if args.firmware is None:
+            print("错误:升级时必须指定 --firmware", file=sys.stderr)
+            return 2
+        firmware = args.firmware.expanduser().resolve()
+        if not firmware.is_file() or firmware.suffix.lower() != ".bin":
+            print(f"错误:固件文件不存在或不是 .bin:{firmware}", file=sys.stderr)
+            return 2
+
+        size = firmware.stat().st_size
+        print(f"准备上传: {firmware} ({size} bytes)")
+        with firmware.open("rb") as file:
+            reader = ProgressReader(file, size)
+            result = request_json(
+                args.host,
+                args.port,
+                args.token,
+                "POST",
+                "/ota/update",
+                body=reader,
+                content_length=size,
+                timeout=180.0,
+            )
+        print(result.get("message", "设备已接受升级"))
+
+        if args.no_wait:
+            return 0
+        after = wait_for_reboot(
+            args.host,
+            args.port,
+            args.token,
+            str(before.get("running_partition", "")),
+            args.reboot_timeout,
+        )
+        if after is None:
+            print("警告:未在限定时间内重新连接设备,请检查串口日志或网络连接", file=sys.stderr)
+            return 1
+        print_status("升级后固件", after)
+        print("OTA 升级成功")
+        return 0
+    except (OSError, RuntimeError, http.client.HTTPException) as error:
+        print(f"OTA 失败:{error}", file=sys.stderr)
+        return 1
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())