27 KiB
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. 总体结论
本次修改解决了以下核心问题:
- “服务器连接成功”提示音播放失败后不再被永久跳过。
- WebSocket、多媒体会话和音频任务的启动、停止不再并发交叉执行。
- 上一轮对话遗留的音频包和播放事件不会再错误影响下一轮对话。
- 设备启动后可自动检查固件版本,正常周期为每 6 小时一次。
- OTA 上报使用具有
ota:report权限的设备 JWT;遇到401/403时刷新 JWT 并重试一次。 - 可选支持 HMAC 内部签名,但默认不启用,不影响当前只使用设备 JWT 的服务端。
- 固件下载后会校验 HTTPS 状态、文件大小、SHA-256 和 ESP-IDF 镜像格式,全部通过后才切换启动分区。
- 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");
}
这段逻辑有两个问题:
- WebSocket 的“已连接”只代表网络连接完成,不代表音频播放链路已经准备好。
- 播放接口的返回值被忽略。即使播放失败,状态也已经变成“播放过”,之后不会再尝试。
因此,只要连接成功时音频通道正在初始化、停止、切换或者被其他播放占用,就可能出现提示音完全不播放。没有 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 提交在此基础上实现了安全、可恢复、资源受控的固件升级流程。
当前验证结果说明“启动检查无更新”的主流程已经稳定运行。下一阶段最重要的验证是使用测试固件完成一次真实升级,覆盖以下场景:
- 非强制更新、等待确认后升级;
- 强制更新;
- 下载中途断网;
- 固件大小不一致;
- SHA-256 不一致;
- 新镜像启动失败后的自动回滚;
- OTA 检查时设备正在对话,确认不会中断当前交互。