850 lines
27 KiB
Markdown
850 lines
27 KiB
Markdown
# 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 检查时设备正在对话,确认不会中断当前交互。
|