feat(ota): defer updates when battery is low
This commit is contained in:
@@ -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,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
|
||||
|
||||
@@ -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 状态引脚只能表示正在充电,不能保证供电线不会在升级途中被拔掉。
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user