feat(ota): defer updates when battery is low

This commit is contained in:
chenjintao
2026-08-20 17:26:11 +08:00
parent 8d3d66c45f
commit 0542aadaa4
6 changed files with 369 additions and 0 deletions
@@ -69,6 +69,7 @@ typedef enum {
CONTROL_PLANE_OTA_STATE_WAITING_FOR_RESOURCES,
CONTROL_PLANE_OTA_STATE_CHECKING,
CONTROL_PLANE_OTA_STATE_WAITING_FOR_CONFIRMATION,
CONTROL_PLANE_OTA_STATE_WAITING_FOR_POWER,
CONTROL_PLANE_OTA_STATE_DOWNLOADING,
CONTROL_PLANE_OTA_STATE_READY_TO_REBOOT,
CONTROL_PLANE_OTA_STATE_ERROR,
@@ -16,6 +16,7 @@
#include "esp_attr.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "sdkconfig.h"
@@ -57,6 +58,12 @@
#ifndef CONFIG_TQ_OTA_MIN_LARGEST_DMA_BYTES
#define CONFIG_TQ_OTA_MIN_LARGEST_DMA_BYTES 4096
#endif
#ifndef CONFIG_TQ_OTA_MIN_BATTERY_PERCENT
#define CONFIG_TQ_OTA_MIN_BATTERY_PERCENT 20
#endif
#ifndef CONFIG_TQ_OTA_POWER_RETRY_MS
#define CONFIG_TQ_OTA_POWER_RETRY_MS 60000
#endif
#ifndef CONFIG_TQ_NETWORK_TASK_CORE
#define CONFIG_TQ_NETWORK_TASK_CORE 0
#endif
@@ -87,6 +94,12 @@ typedef struct {
domain_ota_report_request_t request;
} control_plane_ota_report_context_t;
typedef enum {
CONTROL_PLANE_OTA_POWER_READY = 0,
CONTROL_PLANE_OTA_POWER_LOW,
CONTROL_PLANE_OTA_POWER_UNAVAILABLE,
} control_plane_ota_power_state_t;
static const char *TAG = "cp_ota";
static portMUX_TYPE s_ota_lock = portMUX_INITIALIZER_UNLOCKED;
static TaskHandle_t s_ota_task;
@@ -310,6 +323,49 @@ static bool control_plane_ota_runtime_is_idle(void)
!state.local_responding_started;
}
static control_plane_ota_power_state_t control_plane_ota_get_power_state(
domain_battery_status_t *out_status,
int64_t *out_age_ms,
esp_err_t *out_error)
{
if (out_status == NULL || out_age_ms == NULL || out_error == NULL) {
return CONTROL_PLANE_OTA_POWER_UNAVAILABLE;
}
memset(out_status, 0, sizeof(*out_status));
*out_age_ms = -1;
*out_error = ESP_OK;
#if CONFIG_TQ_BATTERY_ENABLE
esp_err_t err = domain_battery_get_status(out_status);
if (err != ESP_OK) {
*out_error = err;
return CONTROL_PLANE_OTA_POWER_UNAVAILABLE;
}
int64_t now_ms = esp_timer_get_time() / 1000;
if (out_status->last_valid_sample_time_ms > 0 &&
now_ms >= out_status->last_valid_sample_time_ms) {
*out_age_ms = now_ms - out_status->last_valid_sample_time_ms;
}
int64_t max_age_ms = (int64_t)CONFIG_TQ_BATTERY_SAMPLE_INTERVAL_SEC * 3000LL;
if (max_age_ms < 30000LL) {
max_age_ms = 30000LL;
}
if (!out_status->valid || *out_age_ms < 0 || *out_age_ms > max_age_ms) {
return CONTROL_PLANE_OTA_POWER_UNAVAILABLE;
}
if (out_status->percentage < CONFIG_TQ_OTA_MIN_BATTERY_PERCENT) {
return CONTROL_PLANE_OTA_POWER_LOW;
}
#else
(void)out_status;
#endif
return CONTROL_PLANE_OTA_POWER_READY;
}
static void control_plane_ota_set_voice_services_reserved(bool reserved)
{
portENTER_CRITICAL(&s_ota_lock);
@@ -720,6 +776,39 @@ static void control_plane_ota_worker(void *arg)
continue;
}
domain_battery_status_t battery = {0};
int64_t battery_age_ms = -1;
esp_err_t battery_err = ESP_OK;
control_plane_ota_power_state_t power_state =
control_plane_ota_get_power_state(&battery,
&battery_age_ms,
&battery_err);
if (power_state != CONTROL_PLANE_OTA_POWER_READY) {
initial_check_pending = false;
control_plane_ota_release_voice_window(&voice_window);
control_plane_ota_set_state(CONTROL_PLANE_OTA_STATE_WAITING_FOR_POWER,
ESP_OK,
response->http_status);
if (power_state == CONTROL_PLANE_OTA_POWER_LOW) {
ESP_LOGW(TAG,
"OTA %s deferred: battery=%u%% minimum=%u%% charging=%d",
response->firmware.version,
(unsigned)battery.percentage,
(unsigned)CONFIG_TQ_OTA_MIN_BATTERY_PERCENT,
battery.charge_state == DOMAIN_BATTERY_CHARGE_CHARGING ? 1 : 0);
} else {
ESP_LOGW(TAG,
"OTA %s deferred: battery status unavailable valid=%d age_ms=%lld err=%s",
response->firmware.version,
battery.valid ? 1 : 0,
(long long)battery_age_ms,
esp_err_to_name(battery_err));
}
heap_caps_free(response);
control_plane_ota_wait_interruptible(CONFIG_TQ_OTA_POWER_RETRY_MS);
continue;
}
if (!control_plane_ota_runtime_is_idle()) {
ESP_LOGI(TAG, "OTA approved; waiting for active interaction to finish");
initial_check_pending = false;
+10
View File
@@ -10,6 +10,16 @@ exact byte size, SHA-256, and the `force`/`update` decision. Non-forced images
wait for the mini-program confirmation (`update=1`); forced images proceed
without that confirmation.
After an update is approved, the control plane checks the latest valid battery
snapshot before downloading the image. A battery percentage below
`CONFIG_TQ_OTA_MIN_BATTERY_PERCENT`, an unavailable sample, or a stale sample
puts the worker in `CONTROL_PLANE_OTA_STATE_WAITING_FOR_POWER`. This is a
deferred safety condition rather than an OTA failure, so it does not increment
the failure counter. Forced updates do not bypass the gate. The worker releases
voice resources and reports the version again after
`CONFIG_TQ_OTA_POWER_RETRY_MS`, which also obtains a fresh expiring download
URL. Once installation starts, later percentage changes do not cancel it.
Before an HTTPS exchange, the worker quiesces local wake and multimodal voice
services, waits for transport teardown, and checks internal/DMA memory
headroom. The platform layer streams the image into the inactive OTA
+246
View File
@@ -0,0 +1,246 @@
# OTA 低电量保护实现说明
## 一、问题原因
原有 OTA 流程在服务端批准升级后,只检查以下条件:
- Wi-Fi 是否已连接;
- 当前是否存在语音交互;
- OTA 所需 Internal RAM 和 DMA 连续内存是否充足;
- 非强制升级是否已获得小程序确认。
流程没有读取设备的电池状态。因此,即使设备电量只剩 5%,只要服务端下发强制升级,固件仍会开始 HTTPS 下载、写入备用 OTA 分区并重启。
低电量升级存在两个主要风险:
1. 下载和写 Flash 期间功耗增加,设备可能因电压下降而复位。
2. 即使 ESP-IDF 使用双 OTA 分区,反复掉电和重试仍会造成升级失败、重复下载及额外耗电。
电池模块已经维护了统一状态,包括百分比、状态有效性、充电状态和最近一次有效采样时间,因此 OTA 不需要重新读取 ADC,也不应该自己计算电量。
## 二、解决方式
在 OTA 已经获得服务端批准、但尚未开始下载固件的位置增加电量门禁。
处理规则如下:
- 电量小于 20%:禁止开始 OTA。
- 电量等于或高于 20%:允许继续 OTA。
- 电量状态无效、尚未产生有效采样或采样已经过期:禁止开始 OTA。
- 强制升级不能绕过电量门禁。
- 正在充电但电量低于 20%:仍然禁止升级。
- 升级一旦开始,不因百分比在安装过程中下降而主动取消。
- 低电量属于等待条件,不属于 OTA 错误,不增加连续失败次数。
- 等待期间释放 OTA 占用的语音资源,60 秒后重新上报版本并获取新的下载链接。
新增 OTA 状态:
```c
typedef enum {
CONTROL_PLANE_OTA_STATE_DISABLED = 0,
CONTROL_PLANE_OTA_STATE_IDLE,
CONTROL_PLANE_OTA_STATE_WAITING_FOR_NETWORK,
CONTROL_PLANE_OTA_STATE_WAITING_FOR_RESOURCES,
CONTROL_PLANE_OTA_STATE_CHECKING,
CONTROL_PLANE_OTA_STATE_WAITING_FOR_CONFIRMATION,
CONTROL_PLANE_OTA_STATE_WAITING_FOR_POWER,
CONTROL_PLANE_OTA_STATE_DOWNLOADING,
CONTROL_PLANE_OTA_STATE_READY_TO_REBOOT,
CONTROL_PLANE_OTA_STATE_ERROR,
CONTROL_PLANE_OTA_STATE_STOPPED,
} control_plane_ota_state_t;
```
`WAITING_FOR_POWER` 用于表达“升级已获批准,但当前电池条件不允许开始”,避免将其错误归类为网络或安装失败。
## 三、具体代码
### 1. 配置项
```text
CONFIG_TQ_OTA_MIN_BATTERY_PERCENT=20
CONFIG_TQ_OTA_POWER_RETRY_MS=60000
```
对应的 Kconfig 定义:
```kconfig
config TQ_OTA_MIN_BATTERY_PERCENT
int "Minimum battery percentage to start OTA"
default 20
range 0 100
depends on TQ_OTA_ENABLE && TQ_BATTERY_ENABLE
help
Defer an approved OTA update while the latest valid battery
percentage is below this threshold. Forced updates do not
bypass this safety gate. Once installation starts, percentage
changes do not cancel the in-progress update.
config TQ_OTA_POWER_RETRY_MS
int "OTA low-battery recheck interval (ms)"
default 60000
range 10000 3600000
depends on TQ_OTA_ENABLE && TQ_BATTERY_ENABLE
help
Delay before reporting the version again after an approved OTA
update is deferred because battery status is low, unavailable,
or stale. A fresh report also refreshes expiring download URLs.
```
### 2. OTA 内部电量判定结果
```c
typedef enum {
CONTROL_PLANE_OTA_POWER_READY = 0,
CONTROL_PLANE_OTA_POWER_LOW,
CONTROL_PLANE_OTA_POWER_UNAVAILABLE,
} control_plane_ota_power_state_t;
```
三个结果分别代表:
- `READY`:电量信息可信且达到升级门槛。
- `LOW`:电量信息可信,但低于升级门槛。
- `UNAVAILABLE`:电池状态读取失败、无效或过期。
### 3. 电量有效性和门槛判断
```c
static control_plane_ota_power_state_t control_plane_ota_get_power_state(
domain_battery_status_t *out_status,
int64_t *out_age_ms,
esp_err_t *out_error)
{
if (out_status == NULL || out_age_ms == NULL || out_error == NULL) {
return CONTROL_PLANE_OTA_POWER_UNAVAILABLE;
}
memset(out_status, 0, sizeof(*out_status));
*out_age_ms = -1;
*out_error = ESP_OK;
#if CONFIG_TQ_BATTERY_ENABLE
esp_err_t err = domain_battery_get_status(out_status);
if (err != ESP_OK) {
*out_error = err;
return CONTROL_PLANE_OTA_POWER_UNAVAILABLE;
}
int64_t now_ms = esp_timer_get_time() / 1000;
if (out_status->last_valid_sample_time_ms > 0 &&
now_ms >= out_status->last_valid_sample_time_ms) {
*out_age_ms = now_ms - out_status->last_valid_sample_time_ms;
}
int64_t max_age_ms =
(int64_t)CONFIG_TQ_BATTERY_SAMPLE_INTERVAL_SEC * 3000LL;
if (max_age_ms < 30000LL) {
max_age_ms = 30000LL;
}
if (!out_status->valid || *out_age_ms < 0 ||
*out_age_ms > max_age_ms) {
return CONTROL_PLANE_OTA_POWER_UNAVAILABLE;
}
if (out_status->percentage < CONFIG_TQ_OTA_MIN_BATTERY_PERCENT) {
return CONTROL_PLANE_OTA_POWER_LOW;
}
#else
(void)out_status;
#endif
return CONTROL_PLANE_OTA_POWER_READY;
}
```
采样最大有效时间取电池采样周期的 3 倍,并且最少允许 30 秒。默认每 10 秒采样一次,因此默认过期时间为 30 秒。
这里直接比较百分比,而不使用 `DOMAIN_BATTERY_LEVEL_LOW`。原因是现有 LOW 状态在小于等于 20% 时经过连续 3 次确认才进入,并且要恢复到 25% 才退出。直接比较可以准确实现“低于 20% 禁止,20% 允许”的产品规则。
### 4. OTA 下载前门禁
```c
domain_battery_status_t battery = {0};
int64_t battery_age_ms = -1;
esp_err_t battery_err = ESP_OK;
control_plane_ota_power_state_t power_state =
control_plane_ota_get_power_state(&battery,
&battery_age_ms,
&battery_err);
if (power_state != CONTROL_PLANE_OTA_POWER_READY) {
initial_check_pending = false;
control_plane_ota_release_voice_window(&voice_window);
control_plane_ota_set_state(CONTROL_PLANE_OTA_STATE_WAITING_FOR_POWER,
ESP_OK,
response->http_status);
if (power_state == CONTROL_PLANE_OTA_POWER_LOW) {
ESP_LOGW(TAG,
"OTA %s deferred: battery=%u%% minimum=%u%% charging=%d",
response->firmware.version,
(unsigned)battery.percentage,
(unsigned)CONFIG_TQ_OTA_MIN_BATTERY_PERCENT,
battery.charge_state == DOMAIN_BATTERY_CHARGE_CHARGING
? 1 : 0);
} else {
ESP_LOGW(TAG,
"OTA %s deferred: battery status unavailable "
"valid=%d age_ms=%lld err=%s",
response->firmware.version,
battery.valid ? 1 : 0,
(long long)battery_age_ms,
esp_err_to_name(battery_err));
}
heap_caps_free(response);
control_plane_ota_wait_interruptible(CONFIG_TQ_OTA_POWER_RETRY_MS);
continue;
}
```
这段代码位于服务端批准升级之后、固件下载之前。低电量分支不会调用安装函数,也不会显示 OTA 升级表情。
## 四、解决成果
实现后的 OTA 流程具备以下结果:
1. 低于 20% 时,普通升级和强制升级都不会开始下载。
2. 20% 边界行为明确,20% 可以升级,19% 不可以升级。
3. 电池数据不可信时采用保守策略,不冒险升级。
4. 低电量等待不会污染 OTA 失败计数,也不会触发错误退避。
5. 等待时恢复语音服务,不会在 60 秒等待期间持续占用 OTA 网络窗口。
6. 每次重试都会重新上报版本,避免继续使用已经过期的云存储签名链接。
7. ESP-IDF 5.5.4 完整构建通过,新配置已进入生成的 `sdkconfig`。
预期低电量日志:
```text
OTA 1.0.3 deferred: battery=19% minimum=20% charging=0
```
预期电量数据不可用日志:
```text
OTA 1.0.3 deferred: battery status unavailable valid=0 age_ms=-1 err=ESP_OK
```
## 五、后续影响
### 正向影响
- 降低低电量升级过程中掉电或复位的概率。
- 不改变服务端接口和固件下载协议。
- 不改变服务端所需的文件大小、SHA256 字段和 ESP-IDF 双分区校验机制。由于固件代码发生变化,新构建产物的文件大小和 SHA256 需要重新计算。
- 不改变现有电量显示、低电提示音和充电状态逻辑。
### 行为变化
- 设备低于 20% 时,即使服务端下发 `force=1`,也会等待电量恢复。
- 充电状态不会绕过门槛。低于 20% 的设备需要先充到 20%。
- 电池监控初始化失败或长期没有有效采样时,OTA 会持续处于等待电量状态,不会自动冒险升级。
- 低电量期间每 60 秒重新进行一次版本上报,服务端会看到重复检查请求。
### 不处理的情况
- OTA 开始后不会持续监控电量并主动取消。这样可以避免百分比抖动导致反复下载和额外耗电。
- 本次修改不增加“电量不足无法升级”的专用语音或屏幕提示,只提供状态和日志。
- 如果未来需要允许“插着稳定外部电源时低电量升级”,必须增加可靠的外部电源存在检测。当前 TP4057 状态引脚只能表示正在充电,不能保证供电线不会在升级途中被拔掉。
+21
View File
@@ -115,6 +115,27 @@ menu "Example Configuration"
range 2048 32768
depends on TQ_OTA_ENABLE
config TQ_OTA_MIN_BATTERY_PERCENT
int "Minimum battery percentage to start OTA"
default 20
range 0 100
depends on TQ_OTA_ENABLE && TQ_BATTERY_ENABLE
help
Defer an approved OTA update while the latest valid battery
percentage is below this threshold. Forced updates do not
bypass this safety gate. Once installation starts, percentage
changes do not cancel the in-progress update.
config TQ_OTA_POWER_RETRY_MS
int "OTA low-battery recheck interval (ms)"
default 60000
range 10000 3600000
depends on TQ_OTA_ENABLE && TQ_BATTERY_ENABLE
help
Delay before reporting the version again after an approved OTA
update is deferred because battery status is low, unavailable,
or stale. A fresh report also refreshes expiring download URLs.
endmenu
endmenu
+2
View File
@@ -97,6 +97,8 @@ CONFIG_TQ_OTA_RETRY_MAX_MS=21600000
CONFIG_TQ_OTA_TASK_STACK_BYTES=16384
CONFIG_TQ_OTA_MIN_FREE_INTERNAL_BYTES=32768
CONFIG_TQ_OTA_MIN_LARGEST_DMA_BYTES=4096
CONFIG_TQ_OTA_MIN_BATTERY_PERCENT=20
CONFIG_TQ_OTA_POWER_RETRY_MS=60000
CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_ENABLE=y
CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_FILE="local_wake_response.opus"
CONFIG_TQ_LOCAL_WAKE_RETRY_MS=5000