diff --git a/components/control_plane/include/control_plane.h b/components/control_plane/include/control_plane.h index 933156b..845ce9a 100644 --- a/components/control_plane/include/control_plane.h +++ b/components/control_plane/include/control_plane.h @@ -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, diff --git a/components/control_plane/src/runtime/control_plane_ota.c b/components/control_plane/src/runtime/control_plane_ota.c index a5df4bc..6d16bcf 100644 --- a/components/control_plane/src/runtime/control_plane_ota.c +++ b/components/control_plane/src/runtime/control_plane_ota.c @@ -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; diff --git a/docs/ota-firmware-integration.md b/docs/ota-firmware-integration.md index c7c65a6..2c7e1ba 100644 --- a/docs/ota-firmware-integration.md +++ b/docs/ota-firmware-integration.md @@ -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 diff --git a/docs/ota-low-battery-guard.zh-CN.md b/docs/ota-low-battery-guard.zh-CN.md new file mode 100644 index 0000000..9d7ad27 --- /dev/null +++ b/docs/ota-low-battery-guard.zh-CN.md @@ -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 状态引脚只能表示正在充电,不能保证供电线不会在升级途中被拔掉。 diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index 036fcc1..35342ad 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -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 diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 7663708..d9658d3 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -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