/* * SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD * * SPDX-License-Identifier: Apache-2.0 */ #include "control_plane_internal.h" #include "domain.h" #include "driver/gpio.h" #include "esp_attr.h" #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "sdkconfig.h" #if CONFIG_TQ_BATTERY_ENABLE #define BATTERY_WORKER_STACK_BYTES 4096 #define BATTERY_WORKER_PRIORITY 2 #define BATTERY_SUPPRESSED_RETRY_MS 1000 #define BATTERY_STOP_POLL_MS 10 #define BATTERY_CHARGE_DEBOUNCE_MS 30 #define BATTERY_FULL_CONFIRM_RETRY_MS 1000 #define BATTERY_FULL_CONFIRM_TIME_MS 5000 #define BATTERY_FULL_CONFIRM_SAMPLES 3U #define BATTERY_FULL_MIN_VOLTAGE_MV 4050 #define BATTERY_SHUTDOWN_CONFIRM_SAMPLES 3U #define BATTERY_EMERGENCY_CONFIRM_SAMPLES 2U #define BATTERY_SHUTDOWN_RETRY_MS 1000 #define BATTERY_SHUTDOWN_TASK_STACK_BYTES 6144 #define BATTERY_SHUTDOWN_TASK_PRIORITY 5 #define BATTERY_SHUTDOWN_MOTOR_STOP_MS 2000 #define BATTERY_LOW_PROMPT_FILE "/littlefs/audio_prompts/battery_low_20.opus" #define BATTERY_FULL_PROMPT_FILE "/littlefs/audio_prompts/charge_complete.opus" #ifndef CONFIG_TQ_BATTERY_CONTROLLED_SHUTDOWN_ENABLE #define CONFIG_TQ_BATTERY_CONTROLLED_SHUTDOWN_ENABLE 0 #endif #ifndef CONFIG_TQ_BATTERY_SHUTDOWN_PROMPT_FILE #define CONFIG_TQ_BATTERY_SHUTDOWN_PROMPT_FILE "/littlefs/audio_prompts/shutdown_power_empty.opus" #endif #if CONFIG_TQ_BATTERY_CONTROLLED_SHUTDOWN_ENABLE #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 #endif #ifndef CONFIG_TQ_LIGHT_STANDBY_BATTERY_INTERVAL_SEC #define CONFIG_TQ_LIGHT_STANDBY_BATTERY_INTERVAL_SEC 300 #endif static const char *TAG = "cp_battery"; static TaskHandle_t s_battery_task; static bool s_stop_requested; static bool s_starting; static bool s_charge_gpio_isr_registered; static bool s_light_standby; static TaskHandle_t s_shutdown_task; static uint8_t s_shutdown_confirmations; static uint8_t s_emergency_confirmations; 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; static portMUX_TYPE s_battery_lock = portMUX_INITIALIZER_UNLOCKED; static void IRAM_ATTR battery_charge_gpio_isr(void *arg) { (void)arg; TaskHandle_t task = s_battery_task; if (task == NULL) { return; } BaseType_t higher_priority_task_woken = pdFALSE; vTaskNotifyGiveFromISR(task, &higher_priority_task_woken); if (higher_priority_task_woken == pdTRUE) { portYIELD_FROM_ISR(); } } static esp_err_t battery_charge_interrupt_start(void) { if (CONFIG_TQ_BATTERY_CHARGE_GPIO < 0) { return ESP_ERR_NOT_SUPPORTED; } esp_err_t err = gpio_set_intr_type((gpio_num_t)CONFIG_TQ_BATTERY_CHARGE_GPIO, GPIO_INTR_ANYEDGE); if (err != ESP_OK) { return err; } err = gpio_isr_handler_add((gpio_num_t)CONFIG_TQ_BATTERY_CHARGE_GPIO, battery_charge_gpio_isr, NULL); if (err != ESP_OK) { (void)gpio_set_intr_type((gpio_num_t)CONFIG_TQ_BATTERY_CHARGE_GPIO, GPIO_INTR_DISABLE); return err; } s_charge_gpio_isr_registered = true; return ESP_OK; } static void battery_charge_interrupt_stop(void) { if (!s_charge_gpio_isr_registered || CONFIG_TQ_BATTERY_CHARGE_GPIO < 0) { return; } (void)gpio_intr_disable((gpio_num_t)CONFIG_TQ_BATTERY_CHARGE_GPIO); (void)gpio_isr_handler_remove((gpio_num_t)CONFIG_TQ_BATTERY_CHARGE_GPIO); (void)gpio_set_intr_type((gpio_num_t)CONFIG_TQ_BATTERY_CHARGE_GPIO, GPIO_INTR_DISABLE); s_charge_gpio_isr_registered = false; } static const char *charge_state_name(domain_battery_charge_state_t state) { switch (state) { case DOMAIN_BATTERY_CHARGE_CHARGING: return "charging"; case DOMAIN_BATTERY_CHARGE_NOT_CHARGING: return "not_charging"; default: return "unknown"; } } static const char *level_state_name(domain_battery_level_state_t state) { switch (state) { case DOMAIN_BATTERY_LEVEL_NORMAL: return "normal"; case DOMAIN_BATTERY_LEVEL_LOW: return "low"; case DOMAIN_BATTERY_LEVEL_CRITICAL: return "critical"; default: return "unknown"; } } static bool battery_stop_requested(void) { bool requested; portENTER_CRITICAL(&s_battery_lock); requested = s_stop_requested; portEXIT_CRITICAL(&s_battery_lock); return requested; } static bool battery_light_standby(void) { bool standby; portENTER_CRITICAL(&s_battery_lock); standby = s_light_standby; portEXIT_CRITICAL(&s_battery_lock); return standby; } static void battery_worker_finish(void) { portENTER_CRITICAL(&s_battery_lock); s_battery_task = NULL; s_stop_requested = false; s_starting = false; portEXIT_CRITICAL(&s_battery_lock); } static void battery_shutdown_task(void *arg) { (void)arg; portENTER_CRITICAL(&s_battery_lock); s_power_state = BATTERY_POWER_STATE_SHUTTING_DOWN; portEXIT_CRITICAL(&s_battery_lock); ESP_LOGW(TAG, "Controlled low-battery shutdown started"); control_plane_set_runtime_stop_requested(true); (void)control_plane_local_wake_stop(BATTERY_SHUTDOWN_MOTOR_STOP_MS); (void)control_plane_stop_ota_service(BATTERY_SHUTDOWN_MOTOR_STOP_MS); (void)control_plane_stop_multimodal_client(); control_plane_stop_key_input_task(); control_plane_stop_system_event_task(); esp_err_t motor_err = domain_direction_release_motor_hold( BATTERY_SHUTDOWN_MOTOR_STOP_MS); if (motor_err != ESP_OK) { ESP_LOGW(TAG, "Release stepper before shutdown failed: %s", esp_err_to_name(motor_err)); } (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; } /* The product requires the complete prompt before entering deep sleep. */ ESP_LOGE(TAG, "Shutdown prompt did not complete; retrying: %s", esp_err_to_name(prompt_err)); vTaskDelay(pdMS_TO_TICKS(BATTERY_SHUTDOWN_RETRY_MS)); } (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(); ESP_LOGW(TAG, "Shutdown prompt completed; entering deep sleep"); esp_err_t sleep_err = domain_battery_prepare_shutdown(); ESP_LOGE(TAG, "Enter deep sleep failed: %s", esp_err_to_name(sleep_err)); portENTER_CRITICAL(&s_battery_lock); s_power_state = BATTERY_POWER_STATE_RUNNING; s_shutdown_task = NULL; portEXIT_CRITICAL(&s_battery_lock); vTaskDelete(NULL); } static void battery_consider_controlled_shutdown( const domain_battery_status_t *status) { #if CONFIG_TQ_BATTERY_CONTROLLED_SHUTDOWN_ENABLE if (status == NULL) { return; } portENTER_CRITICAL(&s_battery_lock); bool already_requested = s_power_state != BATTERY_POWER_STATE_RUNNING; portEXIT_CRITICAL(&s_battery_lock); if (already_requested) { return; } if (status->charge_state == DOMAIN_BATTERY_CHARGE_CHARGING) { s_shutdown_confirmations = 0U; s_emergency_confirmations = 0U; return; } bool normal_sample = status->valid && !status->measurement_suppressed && status->percentage <= CONFIG_TQ_BATTERY_CRITICAL_ENTER_PERCENT && status->voltage_mv <= CONFIG_TQ_BATTERY_SHUTDOWN_VOLTAGE_MV; bool emergency_sample = status->latest_sample_valid && status->latest_sample_mv <= CONFIG_TQ_BATTERY_EMERGENCY_VOLTAGE_MV; if (normal_sample) { if (s_shutdown_confirmations < UINT8_MAX) { ++s_shutdown_confirmations; } } else { s_shutdown_confirmations = 0U; } if (emergency_sample) { if (s_emergency_confirmations < UINT8_MAX) { ++s_emergency_confirmations; } } else { s_emergency_confirmations = 0U; } if (s_shutdown_confirmations < BATTERY_SHUTDOWN_CONFIRM_SAMPLES && s_emergency_confirmations < BATTERY_EMERGENCY_CONFIRM_SAMPLES) { return; } portENTER_CRITICAL(&s_battery_lock); if (s_power_state != BATTERY_POWER_STATE_RUNNING || s_shutdown_task != NULL) { portEXIT_CRITICAL(&s_battery_lock); return; } s_power_state = BATTERY_POWER_STATE_SHUTDOWN_REQUESTED; portEXIT_CRITICAL(&s_battery_lock); TaskHandle_t task = NULL; BaseType_t created = xTaskCreate(battery_shutdown_task, "cp_bat_shutdown", BATTERY_SHUTDOWN_TASK_STACK_BYTES, NULL, BATTERY_SHUTDOWN_TASK_PRIORITY, &task); portENTER_CRITICAL(&s_battery_lock); if (created == pdPASS && task != NULL) { s_shutdown_task = task; } else { s_power_state = BATTERY_POWER_STATE_RUNNING; } portEXIT_CRITICAL(&s_battery_lock); if (created != pdPASS || task == NULL) { ESP_LOGE(TAG, "Create controlled-shutdown task failed"); } #else (void)status; #endif } static void control_plane_battery_low_hook( const domain_battery_status_t *status) { if (status == NULL) { return; } ESP_LOGI(TAG, "Low-battery prompt start level=%u%% file=%s", (unsigned)status->percentage, BATTERY_LOW_PROMPT_FILE); esp_err_t prompt_err = domain_runtime_behavior_play_prompt_file( BATTERY_LOW_PROMPT_FILE); if (prompt_err != ESP_OK) { ESP_LOGW(TAG, "Low-battery prompt failed: %s", esp_err_to_name(prompt_err)); } } static void control_plane_battery_full_hint_hook( const domain_battery_status_t *status) { if (status == NULL) { return; } ESP_LOGI(TAG, "Inferred charge-complete prompt start voltage=%dmV file=%s", status->voltage_mv, BATTERY_FULL_PROMPT_FILE); esp_err_t prompt_err = domain_runtime_behavior_play_prompt_file( BATTERY_FULL_PROMPT_FILE); if (prompt_err != ESP_OK) { ESP_LOGW(TAG, "Charge-complete prompt failed: %s", esp_err_to_name(prompt_err)); } } static void battery_worker(void *arg) { (void)arg; (void)ulTaskNotifyTake(pdTRUE, portMAX_DELAY); domain_battery_level_state_t previous_level = DOMAIN_BATTERY_LEVEL_UNKNOWN; domain_battery_charge_state_t previous_charge = DOMAIN_BATTERY_CHARGE_UNKNOWN; bool full_hint_candidate = false; TickType_t full_hint_started_at = 0; uint8_t full_hint_confirmations = 0U; esp_err_t init_err = domain_battery_init(); if (init_err != ESP_OK) { ESP_LOGE(TAG, "Battery service initialization failed: %s", esp_err_to_name(init_err)); battery_worker_finish(); vTaskDelete(NULL); return; } esp_err_t interrupt_err = battery_charge_interrupt_start(); if (interrupt_err == ESP_OK) { ESP_LOGI(TAG, "TP4057 charge-state interrupt ready gpio=%d debounce=%dms", CONFIG_TQ_BATTERY_CHARGE_GPIO, BATTERY_CHARGE_DEBOUNCE_MS); } else if (interrupt_err != ESP_ERR_NOT_SUPPORTED) { ESP_LOGW(TAG, "TP4057 charge-state interrupt unavailable; using polling: %s", esp_err_to_name(interrupt_err)); } ESP_LOGI(TAG, "Battery monitoring worker started"); while (!battery_stop_requested()) { esp_err_t sample_err = domain_battery_sample(); domain_battery_status_t status = {0}; esp_err_t status_err = domain_battery_get_status(&status); if (sample_err != ESP_OK) { ESP_LOGW(TAG, "Battery sample rejected: %s", esp_err_to_name(sample_err)); } if (status_err == ESP_OK) { esp_err_t overlay_err = domain_runtime_update_battery_overlay(&status); if (overlay_err != ESP_OK && overlay_err != ESP_ERR_NOT_SUPPORTED) { ESP_LOGD(TAG, "Battery LCD overlay update skipped: %s", esp_err_to_name(overlay_err)); } if (status.valid && !status.measurement_suppressed) { ESP_LOGI(TAG, "Battery adc=%dmV voltage=%dmV level=%u%% charge=%s state=%s", status.latest_adc_mv, status.voltage_mv, (unsigned)status.percentage, charge_state_name(status.charge_state), level_state_name(status.level_state)); } else if (status.measurement_suppressed) { ESP_LOGD(TAG, "Battery SOC update suppressed adc=%dmV raw=%dmV valid=%d load=%d cooldown=%d", status.latest_adc_mv, status.latest_sample_mv, status.latest_sample_valid, status.high_load_active, status.cooldown_active); } if (status.level_state != previous_level) { ESP_LOGW(TAG, "Battery level state changed: %s -> %s", level_state_name(previous_level), level_state_name(status.level_state)); if (status.level_state == DOMAIN_BATTERY_LEVEL_LOW && (previous_level == DOMAIN_BATTERY_LEVEL_NORMAL || previous_level == DOMAIN_BATTERY_LEVEL_UNKNOWN)) { control_plane_battery_low_hook(&status); } previous_level = status.level_state; } if (status.charge_state != previous_charge) { ESP_LOGI(TAG, "Battery charge state changed: %s -> %s", charge_state_name(previous_charge), charge_state_name(status.charge_state)); if (previous_charge == DOMAIN_BATTERY_CHARGE_CHARGING && status.charge_state == DOMAIN_BATTERY_CHARGE_NOT_CHARGING) { /* * TP4057 exposes the same high level for charge termination * and charger removal. Voltage can only make this a useful * UI/audio hint, not a rigorous full-charge measurement. * Never use this inference for safety, shutdown, or power * switching decisions. */ full_hint_candidate = true; full_hint_started_at = xTaskGetTickCount(); full_hint_confirmations = 0U; } else if (status.charge_state != DOMAIN_BATTERY_CHARGE_NOT_CHARGING) { full_hint_candidate = false; full_hint_confirmations = 0U; } previous_charge = status.charge_state; } if (full_hint_candidate && status.valid && !status.measurement_suppressed) { if (status.charge_state != DOMAIN_BATTERY_CHARGE_NOT_CHARGING || status.voltage_mv < BATTERY_FULL_MIN_VOLTAGE_MV) { ESP_LOGI(TAG, "Charge-complete inference cancelled charge=%s voltage=%dmV", charge_state_name(status.charge_state), status.voltage_mv); full_hint_candidate = false; full_hint_confirmations = 0U; } else { if (full_hint_confirmations < UINT8_MAX) { ++full_hint_confirmations; } TickType_t elapsed_ticks = xTaskGetTickCount() - full_hint_started_at; if (full_hint_confirmations >= BATTERY_FULL_CONFIRM_SAMPLES && elapsed_ticks >= pdMS_TO_TICKS(BATTERY_FULL_CONFIRM_TIME_MS)) { control_plane_battery_full_hint_hook(&status); full_hint_candidate = false; full_hint_confirmations = 0U; } } } battery_consider_controlled_shutdown(&status); } uint32_t delay_ms; if (status_err == ESP_OK && status.measurement_suppressed) { delay_ms = BATTERY_SUPPRESSED_RETRY_MS; } else if (full_hint_candidate || s_shutdown_confirmations > 0U || s_emergency_confirmations > 0U) { delay_ms = BATTERY_FULL_CONFIRM_RETRY_MS; } else if (battery_light_standby()) { delay_ms = (uint32_t)CONFIG_TQ_LIGHT_STANDBY_BATTERY_INTERVAL_SEC * 1000U; } else { delay_ms = (uint32_t)CONFIG_TQ_BATTERY_SAMPLE_INTERVAL_SEC * 1000U; } uint32_t notifications = ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(delay_ms)); if (notifications > 0U && !battery_stop_requested()) { vTaskDelay(pdMS_TO_TICKS(BATTERY_CHARGE_DEBOUNCE_MS)); /* Collapse contact bounce into one fresh read of the final level. */ (void)ulTaskNotifyTake(pdTRUE, 0U); } } battery_charge_interrupt_stop(); ESP_LOGI(TAG, "Battery monitoring worker stopped"); battery_worker_finish(); vTaskDelete(NULL); } esp_err_t control_plane_battery_apply_boot_guard(void) { #if CONFIG_TQ_BATTERY_CONTROLLED_SHUTDOWN_ENABLE if (!domain_battery_woke_from_deep_sleep()) { return ESP_OK; } esp_err_t sample_err = domain_battery_sample(); if (sample_err != ESP_OK) { return sample_err; } domain_battery_status_t status = {0}; esp_err_t status_err = domain_battery_get_status(&status); if (status_err != ESP_OK) { return status_err; } ESP_LOGW(TAG, "Deep-sleep wake battery check voltage=%dmV charge=%s valid=%d", status.voltage_mv, charge_state_name(status.charge_state), status.valid); 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; } ESP_LOGW(TAG, "Battery has not recovered; returning to deep sleep"); return domain_battery_prepare_shutdown(); #else return ESP_ERR_NOT_SUPPORTED; #endif } esp_err_t control_plane_battery_service_start(void) { portENTER_CRITICAL(&s_battery_lock); if (s_battery_task != NULL || s_starting) { portEXIT_CRITICAL(&s_battery_lock); return ESP_OK; } s_stop_requested = false; s_starting = true; s_light_standby = false; portEXIT_CRITICAL(&s_battery_lock); TaskHandle_t task = NULL; BaseType_t created = xTaskCreate(battery_worker, "cp_battery", BATTERY_WORKER_STACK_BYTES, NULL, BATTERY_WORKER_PRIORITY, &task); if (created != pdPASS || task == NULL) { portENTER_CRITICAL(&s_battery_lock); s_starting = false; portEXIT_CRITICAL(&s_battery_lock); return ESP_ERR_NO_MEM; } portENTER_CRITICAL(&s_battery_lock); s_battery_task = task; s_starting = false; portEXIT_CRITICAL(&s_battery_lock); xTaskNotifyGive(task); return ESP_OK; } esp_err_t control_plane_battery_service_stop(int timeout_ms) { if (timeout_ms < 0) { return ESP_ERR_INVALID_ARG; } TaskHandle_t task; portENTER_CRITICAL(&s_battery_lock); task = s_battery_task; s_stop_requested = task != NULL; portEXIT_CRITICAL(&s_battery_lock); if (task == NULL) { return ESP_OK; } xTaskNotifyGive(task); int waited_ms = 0; while (waited_ms < timeout_ms) { portENTER_CRITICAL(&s_battery_lock); task = s_battery_task; portEXIT_CRITICAL(&s_battery_lock); if (task == NULL) { return ESP_OK; } vTaskDelay(pdMS_TO_TICKS(BATTERY_STOP_POLL_MS)); waited_ms += BATTERY_STOP_POLL_MS; } portENTER_CRITICAL(&s_battery_lock); task = s_battery_task; s_battery_task = NULL; s_stop_requested = false; portEXIT_CRITICAL(&s_battery_lock); if (task != NULL) { ESP_LOGW(TAG, "Battery worker stop timeout; forcing teardown"); vTaskDelete(task); battery_charge_interrupt_stop(); return ESP_ERR_TIMEOUT; } return ESP_OK; } void control_plane_battery_service_set_light_standby(bool standby) { TaskHandle_t task; portENTER_CRITICAL(&s_battery_lock); s_light_standby = standby; task = s_battery_task; portEXIT_CRITICAL(&s_battery_lock); if (task != NULL) { xTaskNotifyGive(task); } } #else esp_err_t control_plane_battery_service_start(void) { return ESP_ERR_NOT_SUPPORTED; } esp_err_t control_plane_battery_apply_boot_guard(void) { return ESP_ERR_NOT_SUPPORTED; } esp_err_t control_plane_battery_service_stop(int timeout_ms) { return timeout_ms < 0 ? ESP_ERR_INVALID_ARG : ESP_OK; } void control_plane_battery_service_set_light_standby(bool standby) { (void)standby; } #endif