Files
lucky-cat/docs/controlled-low-battery-shutdown.zh-CN.md
T

14 KiB
Raw Blame History

极低电量受控关机方案与实现说明

1. 文档范围

本文说明提交 23f4969 feat(power): add controlled low-battery shutdown 引入的极低电量受控关机功能,包括问题原因、解决方式、关键代码、实现成果和后续影响。

本次实现中的“关机”是完成业务收尾后进入 ESP32-S3 Deep Sleep。由于当前硬件未启用电源保持脚,软件不能切断整块电路板的物理电源,因此它不等同于硬件完全断电。

2. 问题原因

2.1 原有临界电量状态只有检测,没有处理流程

原电量管理已经能够将电量划分为正常、低电量和临界电量,并在电量低于默认 5% 时进入 CRITICAL 状态。但是临界状态对应的处理钩子是空实现,只保留了接口和注释,不会停止负载、播放关机提示或进入休眠。

因此,电池继续放电时,设备最终只能依赖以下非受控结果结束运行:

  • ESP32-S3 供电电压跌破工作范围后复位或掉电;
  • 锂电池保护板达到保护阈值后切断电池输出;
  • 音频、电机、Wi-Fi 等负载产生瞬态压降,提前触发复位。

这些情况都无法保证音频、网络、Flash 写入和外设驱动处于安全状态。

2.2 只使用电量百分比不够可靠

电量百分比由电池电压曲线估算,存在 ADC 误差、滤波延迟和负载压降影响。如果只依赖一个百分比阈值,可能发生误关机或关机过晚。

因此受控关机同时使用:

  • 估算电量百分比;
  • 滤波后的稳定电压;
  • 未滤波的最新有效电压;
  • 充电状态;
  • 连续采样次数;
  • 高负载采样屏蔽状态。

2.3 Deep Sleep 不会自动关闭板外外设

ESP32-S3 进入 Deep Sleep 后,CPU、FreeRTOS 任务、Wi-Fi 和大部分片上外设停止运行,但 PA、Codec、屏幕背光、电机驱动等板外器件不会因为 CPU 休眠而自动断电。

如果没有提前关闭并保持相关 GPIO 的安全电平,外设可能继续耗电,甚至因 GPIO 悬空再次开启。

2.4 Deep Sleep 唤醒相当于一次重新启动

Deep Sleep 唤醒后会重新经过 Bootloader 和 app_main(),不会从休眠函数后继续执行。如果低电量尚未恢复就直接执行开机音、亮屏、联网等完整启动流程,可能再次产生大负载并形成“唤醒—掉压—复位”的循环。

3. 解决方式

3.1 关机触发条件

实现了普通临界条件和紧急电压条件两条路径。

普通临界条件必须同时满足:

未处于充电状态
电量百分比 <= 5%
滤波电压 <= 3450mV
当前样本有效
当前不处于高负载或高负载冷却屏蔽期
连续确认3次

进入候选状态后采样间隔缩短为1秒,因此正常情况下约需3秒完成确认。

紧急条件为:

未处于充电状态
最新有效原始电压 <= 3350mV
连续确认2次

紧急条件不等待高负载屏蔽窗口结束,避免真实电压已经过低时仍继续运行。普通条件和紧急条件最终进入同一套关机流程,都会播放完整的低电关机语音。

只要检测到正在充电,两组确认计数都会清零,不会发起低电关机。

3.2 防止重复关机

使用统一状态机保证关机只发起一次:

typedef enum {
    BATTERY_POWER_STATE_RUNNING = 0,
    BATTERY_POWER_STATE_SHUTDOWN_REQUESTED,
    BATTERY_POWER_STATE_SHUTTING_DOWN,
} battery_power_state_t;

static battery_power_state_t s_power_state = BATTERY_POWER_STATE_RUNNING;

只有 RUNNING 状态能够创建关机任务。任务创建前先切换到 SHUTDOWN_REQUESTED,任务启动后切换到 SHUTTING_DOWN,避免多个采样周期重复创建任务。

3.3 关机执行顺序

受控关机任务执行以下步骤:

  1. 设置全局 runtime stop 状态,阻止网络和业务任务重新拉起;
  2. 停止本地唤醒、OTA、服务器会话、按键事件和系统事件;
  3. 等待并释放电机保持电流;
  4. 停止当前正在播放的下行音频;
  5. 初始化必要的本地音频通路;
  6. 完整播放 shutdown_power_empty.opus;
  7. 提示音失败时每1秒重试,不进入 Deep Sleep;
  8. 提示音播放完成后关闭 PA、Codec、背光和 Wi-Fi;
  9. 保存最小化关机记录;
  10. 将高耗电外设 GPIO 拉低并保持;
  11. 配置唤醒 GPIO;
  12. 进入 Deep Sleep。

3.4 强制完成关机语音

提示音播放接口会等待 Opus 数据全部播放完成后才返回 ESP_OK。关机任务利用该同步语义保证“播完以后才能关机”。

核心代码如下:

(void)domain_runtime_multimodal_audio_stop_downstream_playback();
for (;;) {
    esp_err_t audio_init_err = domain_runtime_multimodal_audio_init();
    esp_err_t prompt_err = audio_init_err == ESP_OK
            ? domain_runtime_behavior_play_prompt_file(
                    CONFIG_TQ_BATTERY_SHUTDOWN_PROMPT_FILE)
            : audio_init_err;
    if (prompt_err == ESP_OK) {
        break;
    }

    ESP_LOGE(TAG,
             "Shutdown prompt did not complete; retrying: %s",
             esp_err_to_name(prompt_err));
    vTaskDelay(pdMS_TO_TICKS(1000));
}

这意味着:

  • 文件不存在、音频初始化失败或播放未完成时,不会进入 Deep Sleep;
  • 软件会持续重试;
  • 这是产品要求优先于快速休眠的明确策略。

3.5 外设关闭和 GPIO 保持

提示音完成后先关闭运行中的外设:

(void)domain_runtime_multimodal_audio_set_speaker_output_enabled(false);
domain_runtime_multimodal_audio_deinit();
(void)domain_runtime_set_lcd_backlight_brightness(0U);
(void)domain_runtime_wifi_portal_stop_sta();

进入 Deep Sleep 前,将以下 GPIO 设为输出低电平并启用保持:

  • PA 使能 GPIO;
  • 屏幕背光 GPIO;
  • 步进电机 IN1、IN2、IN3、IN4 GPIO。

核心实现如下:

static void platform_power_set_output_low_and_hold(int gpio)
{
    if (gpio < 0 || gpio >= GPIO_NUM_MAX) {
        return;
    }
    (void)gpio_hold_dis((gpio_num_t)gpio);
    (void)gpio_set_direction((gpio_num_t)gpio, GPIO_MODE_OUTPUT);
    (void)gpio_set_level((gpio_num_t)gpio, 0);
    (void)gpio_hold_en((gpio_num_t)gpio);
}

所有目标 GPIO 设置完成后调用:

gpio_deep_sleep_hold_en();

这样可避免 ESP32-S3 休眠后引脚悬空导致 PA、背光或电机驱动继续耗电。

3.6 Deep Sleep 唤醒源

当前默认只启用 TP4057 充电状态 GPIO10 作为低电平唤醒源。低电关机后插入充电器,TP4057 开始充电并将 GPIO10 拉低,从而唤醒 ESP32-S3。

实体按键唤醒已经做成配置项,但默认值为 -1,即暂不启用:

CONFIG_TQ_BATTERY_SHUTDOWN_WAKE_BUTTON_GPIO=-1

唤醒源使用 EXT1 的任意低电平模式:

esp_err_t wake_err = esp_sleep_enable_ext1_wakeup_io(
        wake_mask,
        ESP_EXT1_WAKEUP_ANY_LOW);
if (wake_err != ESP_OK) {
    return wake_err;
}

esp_deep_sleep_start();

当以后需要启用实体按键时,应填写支持 Deep Sleep 唤醒的 GPIO,并确保按键按下时为低电平。

3.7 唤醒后的启动保护

在存储初始化完成后、开机音和屏幕初始化之前执行电池启动保护:

esp_err_t battery_guard_err = control_plane_battery_apply_boot_guard();
if (battery_guard_err != ESP_OK &&
    battery_guard_err != ESP_ERR_NOT_SUPPORTED) {
    ESP_LOGW(TAG,
             "Battery boot guard failed, continue boot: %s",
             esp_err_to_name(battery_guard_err));
}

保护逻辑首先判断本次启动是否来自 Deep Sleep,然后读取充电状态和电池电压:

if (status.charge_state == DOMAIN_BATTERY_CHARGE_CHARGING ||
    (status.valid &&
     status.voltage_mv >= CONFIG_TQ_BATTERY_WAKE_RECOVERY_VOLTAGE_MV)) {
    domain_battery_complete_wakeup();
    return ESP_OK;
}

return domain_battery_prepare_shutdown();

默认恢复阈值为 3550mV:

  • 正在充电:允许正常启动;
  • 未充电但电压不低于 3550mV:允许正常启动;
  • 未充电且电压低于 3550mV:在开机音、亮屏和联网前重新进入 Deep Sleep。

正常启动被允许后会解除 PA、背光和电机 GPIO 的 Deep Sleep 保持,否则这些外设无法重新初始化。

3.8 最小化关机记录

进入 Deep Sleep 前以尽力而为方式写入一条短小 NVS 记录:

typedef struct {
    uint8_t version;
    uint8_t reason;
    uint8_t percentage;
    uint8_t reserved;
    int32_t voltage_mv;
} battery_shutdown_record_t;

保存位置:

namespace: battery
key: last_shutdown

写入内容包括记录版本、关机原因、电量百分比和滤波电压。写入失败不会阻止设备进入 Deep Sleep,避免存储异常造成无法关机。

3.9 默认配置

受控关机功能:启用
普通关机电压:3450mV
紧急关机电压:3350mV
唤醒恢复电压:3550mV
临界电量百分比:5%
普通确认次数:3次
紧急确认次数:2次
确认期间采样间隔:1秒
实体按键唤醒 GPIO:-1(关闭)
关机提示音:/littlefs/audio_prompts/shutdown_power_empty.opus
充电唤醒 GPIO:GPIO10,低电平有效

编译期间还会检查阈值关系:

#if CONFIG_TQ_BATTERY_EMERGENCY_VOLTAGE_MV >= CONFIG_TQ_BATTERY_SHUTDOWN_VOLTAGE_MV
#error "Emergency battery voltage must be below the controlled shutdown threshold"
#endif

#if CONFIG_TQ_BATTERY_WAKE_RECOVERY_VOLTAGE_MV <= CONFIG_TQ_BATTERY_SHUTDOWN_VOLTAGE_MV
#error "Battery wake recovery voltage must exceed the shutdown threshold"
#endif

4. 解决成果

本次修改达成以下结果:

  • 临界电量不再仅记录日志,而是进入完整受控关机流程;
  • 普通阈值结合百分比、滤波电压、有效采样和连续确认,降低误触发概率;
  • 紧急电压通道避免电池已经极低时仍长时间等待;
  • 充电状态会阻止尚未触发的低电关机;
  • 关机状态只允许发起一次,不会创建多个关机任务;
  • 关机语音必须完整播放,失败时持续重试;
  • 网络、音频、电机、PA、Codec 和背光在休眠前有序关闭;
  • 高耗电外设控制 GPIO 在 Deep Sleep 中保持低电平;
  • GPIO10 可在插入充电器并开始充电后唤醒设备;
  • 低电量未恢复时不会执行完整开机流程;
  • 保存最小化低电关机诊断记录;
  • 实体按键唤醒接口已经预留且可配置;
  • ESP-IDF v5.5.4 完整构建通过;
  • 生成固件大小约 0x2173a0 字节,应用分区剩余约 40%。

5. 后续影响与风险

5.1 仍然不是物理断电

当前电源保持 GPIO 为 -1,Deep Sleep 只能降低 ESP32-S3 和受控外设的功耗,不能切断板级电源。电池分压电阻、充电芯片以及其他常供电电路仍有静态电流。

如果产品要求长期零待机或接近零待机,需要增加负载开关、MOS 管或电源锁存电路,由 ESP32-S3 在关机末尾释放电源保持信号。

5.2 关机语音优先策略可能增加掉电风险

产品要求任何低电关机都必须完整播放语音,因此实现不会因为电压极低而跳过语音。PA 和扬声器属于高负载,电池状态特别差时可能在语音播放期间发生电压跌落或硬件保护板提前切断。

如果台架测试发现 3350mV 附近无法稳定播放,应优先调整紧急阈值,使关机更早触发,而不是跳过提示音。例如可以将紧急阈值提高到 3400mV 或根据实测负载压降进一步校准。

5.3 提示音资源异常会阻止 Deep Sleep

因为要求“播完才能关机”,提示文件缺失、损坏、Codec 初始化失败或播放通路持续故障时,软件会每秒重试而不会休眠。这符合当前产品决策,但可能导致电池最终依靠保护板断电。

量产前必须验证 LittleFS 中始终包含 shutdown_power_empty.opus,并验证关机场景下音频通路可初始化。

5.4 GPIO10 只能在实际充电时可靠唤醒

TP4057 状态脚低电平表示正在充电,高电平可能表示充满或未插电。低电休眠后插入充电器通常会进入充电并拉低 GPIO10,因此可以唤醒;但如果外围电路或电池状态导致 TP4057 没有进入充电,GPIO10 不会产生有效低电平唤醒。

实体按键唤醒尚未启用,当前无法仅通过按键唤醒 Deep Sleep 中的设备。

5.5 Deep Sleep 唤醒是新的软件生命周期

唤醒后会重新执行 Bootloader 和 app_main(),运行内存、任务状态和普通全局变量不会保留。需要跨休眠保留的数据必须写入 NVS、LittleFS 或 RTC 内存。

5.6 NVS 写入带来少量 Flash 磨损

每次受控低电关机会写入一次 last_shutdown 记录。正常产品使用中低电关机次数有限,影响很小;如果测试环境频繁循环触发低电关机,应注意写入次数。

5.7 需要真机验证的项目

软件构建通过不等于硬件功耗和唤醒行为已经验证。至少需要完成以下测试:

  1. 3450mV 普通路径是否稳定完成3次确认;
  2. 3350mV 紧急路径是否稳定完成2次确认;
  3. 两条路径是否都完整播放关机语音;
  4. 语音播放期间 3.3V 电源是否发生复位级压降;
  5. PA、背光和四路电机 GPIO 在 Deep Sleep 中是否保持低电平;
  6. Deep Sleep 整机电流是否符合产品目标;
  7. 休眠后插入充电器是否由 GPIO10 稳定唤醒;
  8. 电压低于3550mV且未充电时是否在开机音前重新休眠;
  9. 充电状态下即使电压低于3550mV是否允许正常启动;
  10. 提示音文件异常时是否按预期持续重试并输出日志;
  11. NVS 中的 battery/last_shutdown 是否记录正确;
  12. 多次 Deep Sleep 唤醒后 GPIO 保持是否正确解除,PA、背光和电机能否正常恢复。

6. 结论

本次修改把原来的“临界电量状态检测”扩展为完整的“检测、业务收尾、强制语音提示、外设低功耗处理、Deep Sleep、充电唤醒和启动保护”闭环。

它显著降低了极低电量下随机复位、外设持续耗电和反复启动的风险,但受当前硬件电源架构限制,仍属于软件低功耗关机。最终阈值和可靠性需要结合扬声器播放时的实际电压跌落及整机 Deep Sleep 电流进行台架校准。