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

850 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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(
"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 <device-jwt>
```
设备在 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: <Unix 时间戳>
X-Internal-Nonce: <随机数>
X-Internal-Signature: <HMAC-SHA256 后的 Base64>
```
签名原文由请求方法、路径、查询参数、请求体摘要、时间戳和随机数组成:
```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 检查时设备正在对话,确认不会中断当前交互。