# OTA 功能与多媒体稳定性修复说明 ## 1. 文档目的 本文整理以下两个提交的背景、问题原因、解决方式、关键代码、验证成果和后续影响: ```text 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 连接成功事件时,先把提示音状态设置成“已经播放”,然后立即调用音频播放接口: ```c 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( "/littlefs/audio_prompts/server_connect_success.opus"); } ``` 这段逻辑有两个问题: 1. WebSocket 的“已连接”只代表网络连接完成,不代表音频播放链路已经准备好。 2. 播放接口的返回值被忽略。即使播放失败,状态也已经变成“播放过”,之后不会再尝试。 因此,只要连接成功时音频通道正在初始化、停止、切换或者被其他播放占用,就可能出现提示音完全不播放。没有 OTA 时也可能发生;OTA 增加启动阶段的 HTTPS 和语音服务恢复流程后,更容易撞上这个时间窗口。 #### 解决方式 连接成功事件现在只负责登记“待播放”,真正的播放由多媒体监控任务在以下条件满足后执行: - WebSocket 仍然处于连接状态; - 当前没有正在进行的云端交互任务; - 当前没有另一线程正在播放该提示音; - 已达到下一次允许重试的时间。 关键代码如下: ```c #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); } ``` 播放结束后才根据真实返回值更新状态: ```c 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 秒的有限等待时间: ```c #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; } ``` 启动和停止都必须先拿到同一把锁: ```c 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 上一轮音频事件污染下一轮对话 #### 问题原因 音频数据、解码任务和播放完成回调运行在不同任务中。当用户快速打断上一轮回复并立即开始下一轮时,可能出现以下顺序: ```text 第 1 轮开始播放 用户打断第 1 轮 第 2 轮开始播放 第 1 轮的“播放结束”事件晚到 第 2 轮被错误地标记为结束 ``` 旧实现只有“当前是否播放中”的布尔状态,不能判断某个音频包或事件属于哪一轮回复。 #### 解决方式 每次开始下行回复时生成一个递增的 `playback_generation`,可以把它理解为“播放批次号”: ```c 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; } ``` 开始新回复时把本轮批次号返回给会话层: ```c 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; } ``` 每个音频包在解码前检查批次号: ```c snapshot_downstream_playback_state( &playback_generation, &decoder_reset_pending); if (playback_generation != expected_generation) { return ESP_ERR_INVALID_STATE; } ``` 播放事件到达控制层时再次检查: ```c 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 启动失败后的残留回调 #### 问题原因 多媒体客户端启动包含注册回调、启动监控任务、初始化音频、获取鉴权和建立传输等多个步骤。如果中途失败,已经注册的回调可能继续指向一次未完成的会话。 #### 解决方式 启动失败时停止监控任务并注销已经注册的回调: ```c 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 完整流程 设备端流程如下: ```text 设备启动 -> 确认当前待验证固件 -> 等待 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` 当前内容为: ```text 1.0.0 ``` 构建时 ESP-IDF 会将版本写入应用描述信息。设备运行时通过应用描述读取版本,而不是从普通配置文件或 NVS 中读取: ```c const esp_app_desc_t *app_desc = esp_app_get_description(); strlcpy(out, app_desc->version, out_len); ``` 因此,发布新固件前必须更新 `version.txt`,否则服务端会一直看到旧版本号。 ### 4.3 OTA 检查时机 默认配置如下: ```c 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,请求体如下: ```json { "deviceId": "设备唯一 ID", "version": "1.0.0", "serialNumber": "设备序列号" } ``` 其中: - `deviceId` 用于确认是哪台设备; - `version` 是当前正在运行的固件版本; - `serialNumber` 可选,用于服务端进一步关联设备。 构造请求体的代码如下: ```c 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: ```http Authorization: Bearer ``` 设备在 OTA 上报前要求 JWT 至少具有 `ota:report` 权限: ```c 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 中的“允许操作清单”。当前设备认识以下四类权限: ```c #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 因签发时间、过期时间或权限集合不满足而被服务端拒绝: ```c 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: ```c CONFIG_TQ_OTA_INTERNAL_AUTH_SECRET="" ``` 只有部署环境明确要求内部签名,并配置非空共享密钥时,设备才增加下面三个请求头: ```http X-Internal-Timestamp: X-Internal-Nonce: <随机数> X-Internal-Signature: ``` 签名原文由请求方法、路径、查询参数、请求体摘要、时间戳和随机数组成: ```c 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 时必须先保证设备时间已经同步。当前代码会拒绝早于合理时间范围的时间戳: ```c time_t now = time(NULL); if ((int64_t)now < 1700000000LL) { return ESP_ERR_INVALID_STATE; } ``` 当前服务端如果没有要求内部 HMAC,必须保持密钥为空,不应自行填写一个与服务端不一致的值。 #### 4.4.4 服务端响应 没有更新时,服务端返回: ```http HTTP/1.1 204 No Content ``` 设备收到 204 后恢复语音服务,并在 6 小时后再次检查。 有更新时,服务端返回 HTTP 200,数据结构类似: ```json { "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`:非强制更新已经由小程序或服务端确认,可以开始更新。 设备严格检查字段类型、长度和数值: ```c 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 操作前申请一个“语音资源窗口”: ```c 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; } ``` 语音服务完全停止后检查内存: ```c 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 检查结束后释放窗口,并根据之前的状态恢复服务: ```c 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 地址: ```c if (url == NULL || strncmp(url, "https://", 8) != 0 || expected_sha256 == NULL || expected_size == 0U || timeout_ms <= 0) { return ESP_ERR_INVALID_ARG; } ``` 设备先选择当前未运行的 OTA 分区,并确认固件能放入该分区: ```c 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` 完全一致: ```c 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、边写入未运行分区,不需要把完整固件放进内存: ```c 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 验证镜像格式: ```c 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 服务会确认当前镜像: ```c 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 实现成果 在最终设备日志中已经确认: ```text 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 检查时设备正在对话,确认不会中断当前交互。