Files
lucky-cat/docs/knowledge vault/ota-and-multimodal-stability-fixes.zh-CN.md
T

27 KiB
Raw Blame History

OTA 功能与多媒体稳定性修复说明

1. 文档目的

本文整理以下两个提交的背景、问题原因、解决方式、关键代码、验证成果和后续影响:

0cfb102 fix(multimodal): harden playback and client lifecycle handling
5a18036 feat(ota): add authenticated firmware update workflow

两个提交需要分开理解:

  • 多媒体稳定性修复处理的是原本就存在的通用并发和时序问题。即使不增加 OTA,也可能在网络重连、快速打断对话或音频初始化较慢时出现。
  • OTA 提交实现了完整的固件检查、鉴权、下载、校验、切换和重启流程,同时增加了 OTA 与语音网络任务之间的资源协调。
  • OTA 改变了设备启动顺序,并增加了一次 HTTPS 请求,因此提高了原有多媒体问题的触发概率,但不是这些问题的唯一根因。

2. 总体结论

本次修改解决了以下核心问题:

  1. “服务器连接成功”提示音播放失败后不再被永久跳过。
  2. WebSocket、多媒体会话和音频任务的启动、停止不再并发交叉执行。
  3. 上一轮对话遗留的音频包和播放事件不会再错误影响下一轮对话。
  4. 设备启动后可自动检查固件版本,正常周期为每 6 小时一次。
  5. OTA 上报使用具有 ota:report 权限的设备 JWT;遇到 401/403 时刷新 JWT 并重试一次。
  6. 可选支持 HMAC 内部签名,但默认不启用,不影响当前只使用设备 JWT 的服务端。
  7. 固件下载后会校验 HTTPS 状态、文件大小、SHA-256 和 ESP-IDF 镜像格式,全部通过后才切换启动分区。
  8. OTA 请求会暂时释放语音相关网络和内存资源,结束后恢复本地唤醒与 WebSocket。

3. 多媒体稳定性修复

3.1 “服务器连接成功”提示音不播放

问题原因

原逻辑在收到 WebSocket 连接成功事件时,先把提示音状态设置成“已经播放”,然后立即调用音频播放接口:

if (!s_server_success_prompt_played) {
    s_server_success_prompt_played = true;
    should_play_prompt = true;
}

if (should_play_prompt) {
    (void)domain_runtime_behavior_play_prompt_file(
            "audio_prompts/server_connect_success.opus");
}

这段逻辑有两个问题:

  1. WebSocket 的“已连接”只代表网络连接完成,不代表音频播放链路已经准备好。
  2. 播放接口的返回值被忽略。即使播放失败,状态也已经变成“播放过”,之后不会再尝试。

因此,只要连接成功时音频通道正在初始化、停止、切换或者被其他播放占用,就可能出现提示音完全不播放。没有 OTA 时也可能发生;OTA 增加启动阶段的 HTTPS 和语音服务恢复流程后,更容易撞上这个时间窗口。

解决方式

连接成功事件现在只负责登记“待播放”,真正的播放由多媒体监控任务在以下条件满足后执行:

  • WebSocket 仍然处于连接状态;
  • 当前没有正在进行的云端交互任务;
  • 当前没有另一线程正在播放该提示音;
  • 已达到下一次允许重试的时间。

关键代码如下:

#define CONTROL_PLANE_SERVER_SUCCESS_PROMPT_RETRY_MS 1000U
#define CONTROL_PLANE_SERVER_SUCCESS_PROMPT_MAX_ATTEMPTS 3U

static void control_plane_schedule_server_success_prompt(void)
{
    uint32_t now_ms = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);

    portENTER_CRITICAL(&s_multimodal_runtime_lock);
    if (!s_server_success_prompt_played &&
        s_server_success_prompt_attempts <
                CONTROL_PLANE_SERVER_SUCCESS_PROMPT_MAX_ATTEMPTS) {
        s_server_success_prompt_pending = true;
        s_server_success_prompt_next_attempt_ms = now_ms;
    }
    portEXIT_CRITICAL(&s_multimodal_runtime_lock);
}

播放结束后才根据真实返回值更新状态:

esp_err_t err = domain_runtime_behavior_play_prompt_file(
        CONTROL_PLANE_SERVER_SUCCESS_PROMPT_FILE);

portENTER_CRITICAL(&s_multimodal_runtime_lock);
s_server_success_prompt_in_progress = false;

if (err == ESP_OK) {
    s_server_success_prompt_played = true;
    s_server_success_prompt_pending = false;
} else if (s_server_success_prompt_attempts >=
                   CONTROL_PLANE_SERVER_SUCCESS_PROMPT_MAX_ATTEMPTS) {
    s_server_success_prompt_pending = false;
} else {
    s_server_success_prompt_next_attempt_ms =
            now_ms + CONTROL_PLANE_SERVER_SUCCESS_PROMPT_RETRY_MS;
}
portEXIT_CRITICAL(&s_multimodal_runtime_lock);

现在只有播放接口返回 ESP_OK 时,提示音才会被标记为播放成功。失败时最多重试 3 次,每次间隔 1 秒。

3.2 多媒体启动和停止相互冲突

问题原因

设备中有多个任务可能操作多媒体客户端:

  • Wi-Fi 状态机在网络恢复后启动 WebSocket;
  • 网络断开或空闲超时时停止 WebSocket;
  • OTA 检查前释放语音网络资源;
  • OTA 检查后恢复 WebSocket 和本地唤醒;
  • 下行音频断流保护可能触发恢复流程。

原来的 starting、stopping 标志只能描述状态,不能保证完整的启动过程和停止过程互斥。可能出现任务 A 正在释放传输和音频资源,任务 B 已经开始重新注册回调或建立连接的情况。

解决方式

在客户端层和会话层分别增加生命周期互斥锁,并设置 30 秒的有限等待时间:

#define CONTROL_PLANE_CLIENT_LIFECYCLE_WAIT_MS 30000U

static bool lock_client_lifecycle(void)
{
    TickType_t timeout_ticks =
            pdMS_TO_TICKS(CONTROL_PLANE_CLIENT_LIFECYCLE_WAIT_MS);

    if (xSemaphoreTake(s_client_lifecycle_mutex, timeout_ticks) != pdTRUE) {
        ESP_LOGW(TAG, "Timeout acquiring multimodal client lifecycle lock");
        return false;
    }
    return true;
}

启动和停止都必须先拿到同一把锁:

esp_err_t control_plane_start_multimodal_client(void)
{
    if (!lock_client_lifecycle()) {
        return ESP_ERR_TIMEOUT;
    }

    esp_err_t err = control_plane_start_multimodal_client_locked();
    unlock_client_lifecycle();
    return err;
}

esp_err_t control_plane_stop_multimodal_client(void)
{
    if (!lock_client_lifecycle()) {
        return ESP_ERR_TIMEOUT;
    }

    stop_watchdog_task();
    esp_err_t err = control_plane_multimodal_stop();
    unlock_client_lifecycle();
    return err;
}

这样可以保证一个完整的停止流程结束后,另一个任务才能启动新的会话。停止接口也从无返回值改为返回 esp_err_t,调用方可以知道停机是否真正完成。

3.3 上一轮音频事件污染下一轮对话

问题原因

音频数据、解码任务和播放完成回调运行在不同任务中。当用户快速打断上一轮回复并立即开始下一轮时,可能出现以下顺序:

第 1 轮开始播放
用户打断第 1 轮
第 2 轮开始播放
第 1 轮的“播放结束”事件晚到
第 2 轮被错误地标记为结束

旧实现只有“当前是否播放中”的布尔状态,不能判断某个音频包或事件属于哪一轮回复。

解决方式

每次开始下行回复时生成一个递增的 playback_generation,可以把它理解为“播放批次号”:

static uint32_t advance_downstream_playback_generation_locked(void)
{
    s_audio.downstream_playback_generation++;
    if (s_audio.downstream_playback_generation == 0U) {
        s_audio.downstream_playback_generation = 1U;
    }

    s_audio.downstream_decoder_reset_pending = true;
    return s_audio.downstream_playback_generation;
}

开始新回复时把本轮批次号返回给会话层:

esp_err_t multimodal_audio_begin_downstream_response(
        uint32_t *out_playback_generation)
{
    portENTER_CRITICAL(&s_audio_runtime_lock);
    uint32_t playback_generation =
            advance_downstream_playback_generation_locked();
    s_audio.downstream_response_active = true;
    portEXIT_CRITICAL(&s_audio_runtime_lock);

    *out_playback_generation = playback_generation;
    return ESP_OK;
}

每个音频包在解码前检查批次号:

snapshot_downstream_playback_state(
        &playback_generation,
        &decoder_reset_pending);

if (playback_generation != expected_generation) {
    return ESP_ERR_INVALID_STATE;
}

播放事件到达控制层时再次检查:

generation_matches = s_current_playback_generation != 0U &&
                     s_current_playback_generation ==
                             event->playback_generation;

if (!generation_matches) {
    ESP_LOGD(TAG,
             "Ignore stale or local playback event type=%d generation=%u",
             (int)event->type,
             (unsigned)event->playback_generation);
    return;
}

因此,旧回复的音频包、停止事件、完成事件或断流事件即使晚到,也只能影响旧批次,不能结束当前新回复。

3.4 启动失败后的残留回调

问题原因

多媒体客户端启动包含注册回调、启动监控任务、初始化音频、获取鉴权和建立传输等多个步骤。如果中途失败,已经注册的回调可能继续指向一次未完成的会话。

解决方式

启动失败时停止监控任务并注销已经注册的回调:

esp_err_t err = control_plane_multimodal_start();
if (err != ESP_OK) {
    stop_watchdog_task();
    (void)control_plane_multimodal_register_downstream_audio_callback(
            NULL,
            NULL);
    (void)control_plane_multimodal_register_event_callback(NULL, NULL);
    return err;
}

会话层在状态锁失败时也会停止已启动的传输、注销音频回调,并停止本地响应事件工作任务,避免“启动一半”的资源留到下一次重试。

3.5 多媒体修复成果

设备实测日志确认:

  • WebSocket 成功连接;
  • “服务器连接成功”提示音在第 1 次尝试即播放成功;
  • 多轮唤醒、ASR、TTS 下发、音频播放和状态上报均正常;
  • 快速开始下一轮对话时,没有观察到旧事件错误结束新回复;
  • 没有出现栈溢出、断言、崩溃或重启。

日志中仍出现两次短暂下行音频断流,约为 410 ms 和 230 ms,但播放器分别在约 290 ms 和 110 ms 后自动恢复,没有丢失整段回复。这属于网络数据到达不均匀,现有缓冲和恢复机制已经正确处理。

3.6 多媒体修复的后续影响

正面影响:

  • Wi-Fi 重连、OTA、空闲断开和异常恢复时的启停行为更稳定;
  • 快速打断和连续对话的状态隔离更可靠;
  • 播放失败和停机超时有明确日志,不再静默失败。

需要注意:

  • 生命周期锁的最长等待时间为 30 秒。如果底层网络或音频停止异常,调用方会收到 ESP_ERR_TIMEOUT,而不是无限等待。
  • 提示音最多重试 3 次。如果连续 3 次都失败,本轮启动中不会无限重试,以免持续占用音频通道。
  • 播放事件结构新增了 playback_generation。以后新增任何播放回调或本地音频来源时,都应明确它属于哪一个播放批次。

4. OTA 功能实现

4.1 OTA 完整流程

设备端流程如下:

设备启动
  -> 确认当前待验证固件
  -> 等待 Wi-Fi
  -> 暂停本地唤醒和 WebSocket
  -> 检查内部内存和 DMA 连续内存
  -> 获取具有 ota:report 权限的新 JWT
  -> POST 上报设备 ID、序列号和当前版本
  -> HTTP 204:没有更新,恢复语音服务,6 小时后再检查
  -> HTTP 200:解析新固件信息
       -> 强制更新:等待当前对话结束后开始下载
       -> 非强制且已确认:等待当前对话结束后开始下载
       -> 非强制且未确认:恢复语音服务,6 小时后再次查询
  -> GET 下载固件
  -> 校验大小、SHA-256 和 ESP-IDF 镜像格式
  -> 设置新启动分区
  -> 重启
  -> 新固件启动成功后确认镜像,取消回滚

OTA 采用三层职责划分:

  • 控制层:决定何时检查、何时暂停和恢复语音、是否允许更新、失败后多久重试。
  • 领域层:构造上报 JSON、添加 JWT/HMAC 请求头、解析服务端响应、转换 SHA-256。
  • 平台层:执行 HTTPS GET、写入 OTA 分区、计算摘要、验证镜像并切换启动分区。

4.2 固件版本存储

仓库根目录的 version.txt 当前内容为:

1.0.0

构建时 ESP-IDF 会将版本写入应用描述信息。设备运行时通过应用描述读取版本,而不是从普通配置文件或 NVS 中读取:

const esp_app_desc_t *app_desc = esp_app_get_description();
strlcpy(out, app_desc->version, out_len);

因此,发布新固件前必须更新 version.txt,否则服务端会一直看到旧版本号。

4.3 OTA 检查时机

默认配置如下:

CONFIG_TQ_OTA_ENABLE=y
CONFIG_TQ_OTA_POLL_INTERVAL_MS=21600000
CONFIG_TQ_OTA_CONFIRMATION_POLL_MS=21600000
CONFIG_TQ_OTA_HTTP_TIMEOUT_MS=15000
CONFIG_TQ_OTA_INSTALL_TIMEOUT_MS=600000
CONFIG_TQ_OTA_TASK_STACK_BYTES=16384
CONFIG_TQ_OTA_MIN_FREE_INTERNAL_BYTES=32768
CONFIG_TQ_OTA_MIN_LARGEST_DMA_BYTES=4096

含义为:

  • 启动联网后立即执行首次检查;
  • 没有更新时,每 6 小时检查一次;
  • 有非强制更新但小程序尚未确认时,每 6 小时重新查询;
  • 版本上报请求最多等待 15 秒;
  • 固件下载和安装总时限为 10 分钟;
  • OTA 独立任务栈为 16 KB;
  • HTTPS 开始前至少保留 32 KB 内部内存和 4 KB 最大 DMA 连续块。

4.4 设备与服务端的数据交换

4.4.1 请求体

设备向版本上报接口发送 HTTP POST,请求体如下:

{
  "deviceId": "设备唯一 ID",
  "version": "1.0.0",
  "serialNumber": "设备序列号"
}

其中:

  • deviceId 用于确认是哪台设备;
  • version 是当前正在运行的固件版本;
  • serialNumber 可选,用于服务端进一步关联设备。

构造请求体的代码如下:

bool ok = cJSON_AddStringToObject(
        root,
        "deviceId",
        request->device_id) != NULL &&
          cJSON_AddStringToObject(
        root,
        "version",
        request->current_version) != NULL;

if (ok && request->serial_number != NULL &&
    request->serial_number[0] != '\0') {
    ok = cJSON_AddStringToObject(
            root,
            "serialNumber",
            request->serial_number) != NULL;
}

4.4.2 JWT 和 scope

请求始终携带设备 JWT:

Authorization: Bearer <device-jwt>

设备在 OTA 上报前要求 JWT 至少具有 ota:report 权限:

control_plane_device_auth_result_t auth_result =
        control_plane_multimodal_device_auth_ensure(
                CONTROL_PLANE_DEVICE_AUTH_SCOPE_OTA_REPORT,
                force_jwt_refresh);

scope 可以理解为 JWT 中的“允许操作清单”。当前设备认识以下四类权限:

#define CONTROL_PLANE_DEVICE_AUTH_SCOPE_VOICE_SESSION (1u << 0)
#define CONTROL_PLANE_DEVICE_AUTH_SCOPE_OTA_REPORT    (1u << 1)
#define CONTROL_PLANE_DEVICE_AUTH_SCOPE_OTA_READ      (1u << 2)
#define CONTROL_PLANE_DEVICE_AUTH_SCOPE_OTA_CONFIRM   (1u << 3)

它们分别表示:

  • voice:session:允许建立语音会话;
  • ota:report:允许上报当前固件版本;
  • ota:read:允许读取 OTA 固件资源;
  • ota:confirm:允许确认升级。

日志中的 scope=0x0000000f 表示低 4 位全部为 1,即上述四种权限全部具备。OTA 版本上报实际只强制要求 ota:report。

首次启动检查会强制刷新 JWT,避免缓存 JWT 因签发时间、过期时间或权限集合不满足而被服务端拒绝:

for (int attempt = 0; attempt < 2; ++attempt) {
    bool force_jwt_refresh = force_initial_jwt_refresh || attempt > 0;

    err = control_plane_ota_prepare_report(
            &ctx->request,
            ctx->device_id,
            sizeof(ctx->device_id),
            ctx->serial_number,
            sizeof(ctx->serial_number),
            ctx->version,
            sizeof(ctx->version),
            ctx->jwt,
            sizeof(ctx->jwt),
            force_jwt_refresh);

    err = domain_runtime_ota_report(&ctx->request, response);

    bool unauthorized = err == ESP_OK &&
            (response->http_status == 401 ||
             response->http_status == 403);
    if (!unauthorized) {
        break;
    }

    control_plane_multimodal_device_auth_invalidate(
            response->http_status);
}

如果服务端仍返回 401/403,设备会让当前 JWT 失效、重新获取,并且只重试一次,避免错误配置造成无限鉴权请求。

4.4.3 可选 HMAC

HMAC 不是 JWT,也不是固件 SHA-256 校验。

  • JWT 证明“这台设备是谁,以及它有哪些权限”。
  • HMAC 证明“请求内容由持有共享密钥的一方生成,并且内容没有被修改”。
  • 固件 SHA-256 证明“下载到的固件文件与服务端声明的文件完全一致”。

默认配置中 HMAC 密钥为空,因此当前请求只发送 JWT:

CONFIG_TQ_OTA_INTERNAL_AUTH_SECRET=""

只有部署环境明确要求内部签名,并配置非空共享密钥时,设备才增加下面三个请求头:

X-Internal-Timestamp: <Unix 时间戳>
X-Internal-Nonce: <随机数>
X-Internal-Signature: <HMAC-SHA256 后的 Base64>

签名原文由请求方法、路径、查询参数、请求体摘要、时间戳和随机数组成:

snprintf(signature_payload,
         (size_t)payload_len + 1U,
         "POST\n%s\n%s\n%s\n%s\n%s",
         path,
         query,
         body_hash,
         timestamp,
         nonce);

mbedtls_md_hmac(md_info,
                (const uint8_t *)secret,
                strlen(secret),
                (const uint8_t *)signature_payload,
                (size_t)payload_len,
                digest);

HMAC 使用时间戳防止旧请求被长期重放,使用随机数防止同一秒内的重复请求被重放。因此启用 HMAC 时必须先保证设备时间已经同步。当前代码会拒绝早于合理时间范围的时间戳:

time_t now = time(NULL);
if ((int64_t)now < 1700000000LL) {
    return ESP_ERR_INVALID_STATE;
}

当前服务端如果没有要求内部 HMAC,必须保持密钥为空,不应自行填写一个与服务端不一致的值。

4.4.4 服务端响应

没有更新时,服务端返回:

HTTP/1.1 204 No Content

设备收到 204 后恢复语音服务,并在 6 小时后再次检查。

有更新时,服务端返回 HTTP 200,数据结构类似:

{
  "code": 200,
  "data": {
    "notUptodate": 1,
    "version": "1.0.1",
    "url": "https://example.com/firmware.bin",
    "sha256": "64 位十六进制 SHA-256",
    "sizeBytes": 2140400,
    "force": 0,
    "update": 1
  }
}

字段含义:

  • notUptodate=1:当前固件不是最新版本;
  • version:目标固件版本;
  • url:固件 HTTPS 下载地址;
  • sha256:固件文件的 SHA-256;
  • sizeBytes:固件精确字节数;
  • force=1:强制更新,不等待小程序确认;
  • update=1:非强制更新已经由小程序或服务端确认,可以开始更新。

设备严格检查字段类型、长度和数值:

if (err != ESP_OK ||
    !cJSON_IsNumber(size_bytes) ||
    size_bytes->valuedouble <= 0 ||
    size_bytes->valuedouble > (double)UINT32_MAX ||
    strlen(firmware->sha256) != 64U) {
    return ESP_ERR_INVALID_RESPONSE;
}

firmware->forced =
        cJSON_IsNumber(forced) && forced->valueint == 1;
firmware->confirmed =
        cJSON_IsNumber(confirmed) && confirmed->valueint == 1;

4.5 OTA 与语音任务的资源协调

问题原因

ESP32-S3 的内部 RAM 和 DMA 连续内存有限。WebSocket、TLS、音频录制、Opus 解码和本地唤醒同时运行时,再启动 OTA HTTPS 可能造成:

  • TLS 建连内存不足;
  • 最大连续 DMA 块不足;
  • WebSocket 和 OTA 同时竞争网络资源;
  • OTA 完成后语音任务没有恢复;
  • 多媒体停止未完成,OTA 已经开始申请大块内存。

解决方式

OTA 在每次 HTTPS 操作前申请一个“语音资源窗口”:

s_ota_voice_services_reserved = true;

esp_err_t wake_stop_err = control_plane_local_wake_stop(1000);
if (wake_stop_err != ESP_OK) {
    return wake_stop_err;
}

esp_err_t stop_err = control_plane_stop_multimodal_client();
if (stop_err != ESP_OK) {
    return stop_err;
}

语音服务完全停止后检查内存:

domain_ota_memory_headroom_t headroom = {0};
esp_err_t err = domain_runtime_ota_get_memory_headroom(&headroom);

if (headroom.free_internal <
            CONFIG_TQ_OTA_MIN_FREE_INTERNAL_BYTES ||
    headroom.largest_dma <
            CONFIG_TQ_OTA_MIN_LARGEST_DMA_BYTES) {
    return ESP_ERR_NO_MEM;
}

OTA 检查结束后释放窗口,并根据之前的状态恢复服务:

control_plane_ota_set_voice_services_reserved(false);

if (window->restore_local_wake &&
    control_plane_voice_turn_is_idle() &&
    control_plane_ota_start_default_local_wake()) {
    ESP_LOGI(TAG,
             "Local wake restore requested after OTA HTTPS window");
}

if (window->restore_multimodal ||
    !control_plane_voice_turn_is_idle()) {
    (void)control_plane_schedule_websocket_wait_task();
}

后续周期检查不会强行打断正在进行的对话。如果当前语音交互未结束,OTA 会进入等待资源状态,等设备空闲后再检查或升级。

4.6 固件下载与完整性校验

下载只接受 HTTPS 地址:

if (url == NULL ||
    strncmp(url, "https://", 8) != 0 ||
    expected_sha256 == NULL ||
    expected_size == 0U ||
    timeout_ms <= 0) {
    return ESP_ERR_INVALID_ARG;
}

设备先选择当前未运行的 OTA 分区,并确认固件能放入该分区:

const esp_partition_t *partition =
        esp_ota_get_next_update_partition(NULL);

if (partition == NULL || expected_size > partition->size) {
    return ESP_ERR_INVALID_SIZE;
}

服务器必须返回 HTTP 200。如果存在 Content-Length,必须和 sizeBytes 完全一致:

int status_code = esp_http_client_get_status_code(client);
if (status_code != 200) {
    return ESP_ERR_INVALID_RESPONSE;
}

if (content_length >= 0 &&
    (uint64_t)content_length != expected_size) {
    return ESP_ERR_INVALID_SIZE;
}

设备边下载、边计算 SHA-256、边写入未运行分区,不需要把完整固件放进内存:

while (received < expected_size) {
    int read_len = esp_http_client_read(
            client,
            (char *)buffer,
            read_capacity);

    mbedtls_sha256_update(
            &sha_ctx,
            buffer,
            (size_t)read_len);

    err = esp_ota_write(
            ota_handle,
            buffer,
            (size_t)read_len);

    received += (uint64_t)read_len;
}

下载结束后比较摘要,再由 ESP-IDF 验证镜像格式:

if (memcmp(actual_sha256,
           expected_sha256,
           sizeof(actual_sha256)) != 0) {
    return ESP_ERR_INVALID_CRC;
}

err = esp_ota_end(ota_handle);
if (err != ESP_OK) {
    return err;
}

err = esp_ota_set_boot_partition(partition);

任何失败都会调用 esp_ota_abort(),当前正在运行的分区保持不变,不会启动一个只下载了一半或校验失败的镜像。

4.7 回滚保护

OTA 配置启用了 ESP-IDF 应用回滚功能。新镜像第一次启动时处于待验证状态,运行时启动 OTA 服务会确认当前镜像:

esp_ota_img_states_t state = ESP_OTA_IMG_UNDEFINED;
esp_err_t err = esp_ota_get_state_partition(running, &state);

if (err == ESP_ERR_NOT_FOUND ||
    state != ESP_OTA_IMG_PENDING_VERIFY) {
    return ESP_OK;
}

return esp_ota_mark_app_valid_cancel_rollback();

如果新固件无法正常启动并完成确认,ESP-IDF 可以回滚到上一份可用固件。

4.8 OTA 实现成果

在最终设备日志中已经确认:

Device JWT ready (exp=1786692109 scope=0x0000000f kv=1)
OTA version report response: http=204
OTA task stack phase=version_report headroom=8072B configured=16384B
OTA HTTPS window released: restore_multimodal=1 restore_local_wake=1
Local wake restore requested after OTA HTTPS window
OTA version report complete: running firmware is current (http=204)
WebSocket connected
Server success prompt played on attempt 1

这组结果证明:

  • 启动检查前成功生成了新 JWT;
  • JWT 权限覆盖 OTA 上报要求;
  • 服务端返回 204,表示当前没有新版本;
  • 没有再次出现 401 或 403;
  • 16 KB OTA 栈在版本上报阶段仍剩余约 8072 B;
  • OTA HTTPS 结束后,本地唤醒和 WebSocket 均成功恢复;
  • 服务器连接成功提示音正常播放;
  • 后续多轮对话和下发音频正常。

ESP-IDF 5.5.4 完整编译通过。生成的应用固件大小约为 0x20a8f0 字节,最小应用分区仍有 0x175710 字节,即约 42% 空间可用。

4.9 OTA 的后续影响和注意事项

启动时间

设备联网后的首次 OTA 检查会先于语音服务执行,因此 WebSocket 和本地唤醒会比以前晚几秒启动。这是有意的资源保护策略,不是故障。版本接口超时上限为 15 秒;失败后会释放 OTA 窗口并恢复语音。

网络流量

正常情况下每 6 小时增加一次很小的 JSON POST。只有服务端下发并批准升级时,设备才会 GET 下载完整固件。

内存

OTA 已通过语音窗口和内存门槛降低 TLS 内存不足风险,但设备稳态内部内存仍然较紧。实测历史最低内部内存约 6.6 KB,虽然 alloc_failures=0 且没有崩溃,后续增加新网络功能时仍需关注内部 RAM、DMA 最大连续块和任务栈。

服务可用性

固件下载期间本地唤醒和云端语音暂时不可用,下载完成后设备会直接重启。这是 OTA 的预期行为。后续如需改善用户体验,可以在升级前播放“系统升级中”提示音,但不能在下载过程中重新启动常驻音频任务。

服务端契约

服务端必须保证以下内容稳定:

  • 版本上报接口接受带 ota:report 权限的设备 JWT;
  • 无更新返回 HTTP 204;
  • 有更新返回完整且类型正确的字段;
  • 固件 URL 使用 HTTPS;
  • sizeBytes 与实际文件长度一致;
  • sha256 是实际二进制文件的 SHA-256;
  • 如果启用 HMAC,设备和服务端必须使用相同密钥、签名格式和时间容差。

版本发布

每次发布新固件前必须更新 version.txt,生成固件后还应记录:

  • 版本号;
  • 文件字节数;
  • SHA-256;
  • 下载 URL;
  • 是否强制升级;
  • 灰度设备范围和回滚策略。

5. 最终评价

这两个提交形成了互补关系:

  • 多媒体提交修复了原有的提示音、启停竞争和旧音频事件问题,使语音链路可以可靠地暂停和恢复。
  • OTA 提交在此基础上实现了安全、可恢复、资源受控的固件升级流程。

当前验证结果说明“启动检查无更新”的主流程已经稳定运行。下一阶段最重要的验证是使用测试固件完成一次真实升级,覆盖以下场景:

  1. 非强制更新、等待确认后升级;
  2. 强制更新;
  3. 下载中途断网;
  4. 固件大小不一致;
  5. SHA-256 不一致;
  6. 新镜像启动失败后的自动回滚;
  7. OTA 检查时设备正在对话,确认不会中断当前交互。