diff --git a/components/app_composition/src/runtime/app_composition_default_runtime.c b/components/app_composition/src/runtime/app_composition_default_runtime.c index e6294a4..1c9ffc0 100644 --- a/components/app_composition/src/runtime/app_composition_default_runtime.c +++ b/components/app_composition/src/runtime/app_composition_default_runtime.c @@ -79,6 +79,14 @@ static esp_err_t app_composition_run_plan_a_boot_sequence(void) return err; } + 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)); + } + app_composition_log_stage(APP_COMPOSITION_BOOT_STAGE_AUDIO_INIT, "audio_init", "boot_sound_policy_begin"); diff --git a/components/control_plane/include/control_plane.h b/components/control_plane/include/control_plane.h index ce0af58..b7d887f 100644 --- a/components/control_plane/include/control_plane.h +++ b/components/control_plane/include/control_plane.h @@ -130,6 +130,12 @@ esp_err_t control_plane_init(void); */ esp_err_t control_plane_battery_service_start(void); +/** + * Re-enter deep sleep before normal boot when a low-battery wake has not + * recovered and no charger is active. + */ +esp_err_t control_plane_battery_apply_boot_guard(void); + /** * @brief Prepare platform-backed persistent storage before runtime workers start. * diff --git a/components/control_plane/internal/control_plane_internal.h b/components/control_plane/internal/control_plane_internal.h index 21ec56b..78e67fc 100644 --- a/components/control_plane/internal/control_plane_internal.h +++ b/components/control_plane/internal/control_plane_internal.h @@ -147,6 +147,7 @@ bool control_plane_voice_turn_is_idle(void); bool control_plane_ota_voice_services_allowed(void); bool control_plane_ota_business_interactions_allowed(void); bool control_plane_runtime_is_stopping(void); +void control_plane_set_runtime_stop_requested(bool stop_requested); esp_err_t control_plane_battery_service_stop(int timeout_ms); void control_plane_battery_service_set_light_standby(bool standby); diff --git a/components/control_plane/src/runtime/control_plane_battery_service.c b/components/control_plane/src/runtime/control_plane_battery_service.c index 2780ebe..b2ffa6d 100644 --- a/components/control_plane/src/runtime/control_plane_battery_service.c +++ b/components/control_plane/src/runtime/control_plane_battery_service.c @@ -25,9 +25,31 @@ #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 @@ -38,6 +60,17 @@ 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) @@ -145,14 +178,137 @@ static void battery_worker_finish(void) portEXIT_CRITICAL(&s_battery_lock); } -static void control_plane_battery_critical_hook( +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; - /* - * Reserved for a future product decision. Do not stop actuators, enter - * sleep, or cut board power in this release. - */ +#endif } static void control_plane_battery_low_hook( @@ -268,9 +424,6 @@ static void battery_worker(void *arg) previous_level == DOMAIN_BATTERY_LEVEL_UNKNOWN)) { control_plane_battery_low_hook(&status); } - if (status.level_state == DOMAIN_BATTERY_LEVEL_CRITICAL) { - control_plane_battery_critical_hook(&status); - } previous_level = status.level_state; } if (status.charge_state != previous_charge) { @@ -321,12 +474,15 @@ static void battery_worker(void *arg) } } } + + 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) { + } 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; @@ -347,6 +503,42 @@ static void battery_worker(void *arg) 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); @@ -442,6 +634,11 @@ 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; diff --git a/components/control_plane/src/runtime/control_plane_runtime_workers.c b/components/control_plane/src/runtime/control_plane_runtime_workers.c index eb0a8ce..d05fd73 100644 --- a/components/control_plane/src/runtime/control_plane_runtime_workers.c +++ b/components/control_plane/src/runtime/control_plane_runtime_workers.c @@ -490,7 +490,7 @@ static bool control_plane_runtime_stop_requested(void) return control_plane_runtime_is_stopping(); } -static void control_plane_set_runtime_stop_requested(bool stop_requested) +void control_plane_set_runtime_stop_requested(bool stop_requested) { portENTER_CRITICAL(&s_runtime_lock); s_runtime_stop_requested = stop_requested; diff --git a/components/domain/include/domain.h b/components/domain/include/domain.h index 0731dac..ef5a9ae 100644 --- a/components/domain/include/domain.h +++ b/components/domain/include/domain.h @@ -67,9 +67,15 @@ esp_err_t domain_battery_sample(void); /** Copy the latest battery SSOT snapshot into `out_status`. */ esp_err_t domain_battery_get_status(domain_battery_status_t *out_status); -/** Reserved policy hook; this release intentionally performs no shutdown. */ +/** Enter the configured board deep-sleep state; a successful call does not return. */ esp_err_t domain_battery_prepare_shutdown(void); +/** Return true when the current boot resumed through a deep-sleep wake source. */ +bool domain_battery_woke_from_deep_sleep(void); + +/** Release low-power GPIO holds after accepting a deep-sleep wake. */ +void domain_battery_complete_wakeup(void); + typedef enum { DOMAIN_WIFI_PROMPT_WIFI_SUCCESS = 0, DOMAIN_WIFI_PROMPT_AFTER_WIFI_SUCCESS, diff --git a/components/domain/src/battery/domain_battery_service.c b/components/domain/src/battery/domain_battery_service.c index 432f6f0..60a0171 100644 --- a/components/domain/src/battery/domain_battery_service.c +++ b/components/domain/src/battery/domain_battery_service.c @@ -40,6 +40,14 @@ typedef struct { uint8_t percentage; } battery_curve_point_t; +typedef struct { + uint8_t version; + uint8_t reason; + uint8_t percentage; + uint8_t reserved; + int32_t voltage_mv; +} battery_shutdown_record_t; + static const battery_curve_point_t s_discharge_curve[] = { {3300, 0}, {3400, 2}, @@ -329,8 +337,37 @@ esp_err_t domain_battery_get_status(domain_battery_status_t *out_status) esp_err_t domain_battery_prepare_shutdown(void) { - /* Reserved for a future board-specific sleep or power-cut sequence. */ +#if CONFIG_TQ_BATTERY_CONTROLLED_SHUTDOWN_ENABLE + battery_shutdown_record_t record = { + .version = 1U, + .reason = 1U, + }; + if (lock_state()) { + record.percentage = s_status.percentage; + record.voltage_mv = s_status.voltage_mv; + unlock_state(); + } + /* Best-effort bounded record; a storage failure must not block deep sleep. */ + (void)platform_storage_set_blob("battery", + "last_shutdown", + &record, + sizeof(record)); + return platform_power_enter_deep_sleep( + CONFIG_TQ_BATTERY_CHARGE_GPIO, + CONFIG_TQ_BATTERY_SHUTDOWN_WAKE_BUTTON_GPIO); +#else return ESP_ERR_NOT_SUPPORTED; +#endif +} + +bool domain_battery_woke_from_deep_sleep(void) +{ + return platform_power_woke_from_deep_sleep(); +} + +void domain_battery_complete_wakeup(void) +{ + platform_power_release_deep_sleep_holds(); } #else @@ -358,4 +395,13 @@ esp_err_t domain_battery_prepare_shutdown(void) return ESP_ERR_NOT_SUPPORTED; } +bool domain_battery_woke_from_deep_sleep(void) +{ + return false; +} + +void domain_battery_complete_wakeup(void) +{ +} + #endif diff --git a/components/platform/CMakeLists.txt b/components/platform/CMakeLists.txt index e7ea45e..f0e8899 100644 --- a/components/platform/CMakeLists.txt +++ b/components/platform/CMakeLists.txt @@ -44,6 +44,7 @@ set(platform_priv_requires app_update esp_http_client esp_http_server + esp_hw_support esp_lcd esp_netif esp_psram diff --git a/components/platform/include/platform.h b/components/platform/include/platform.h index 07bb7b6..11181a4 100644 --- a/components/platform/include/platform.h +++ b/components/platform/include/platform.h @@ -310,6 +310,18 @@ esp_err_t platform_stepper_rotate_angle_with_accel_timeout(float angle, esp_err_t platform_stepper_release_hold(int timeout_ms); void platform_stepper_power_off(void); +/** Return true when this boot was caused by a configured deep-sleep wake source. */ +bool platform_power_woke_from_deep_sleep(void); + +/** Release board-output holds after a deep-sleep wake is accepted. */ +void platform_power_release_deep_sleep_holds(void); + +/** + * Enter deep sleep after configuring active-low charger and optional button + * wake GPIOs. A successful call does not return. + */ +esp_err_t platform_power_enter_deep_sleep(int charger_gpio, int button_gpio); + typedef enum { PLATFORM_BATTERY_CHARGE_UNKNOWN = 0, PLATFORM_BATTERY_CHARGE_NOT_CHARGING, diff --git a/components/platform/src/runtime/platform_core_runtime.c b/components/platform/src/runtime/platform_core_runtime.c index c02f451..823e2c3 100644 --- a/components/platform/src/runtime/platform_core_runtime.c +++ b/components/platform/src/runtime/platform_core_runtime.c @@ -11,6 +11,7 @@ #include "driver/gpio.h" #include "esp_app_desc.h" #include "esp_log.h" +#include "esp_sleep.h" #include "nvs.h" #include "nvs_flash.h" #include "platform_internal.h" @@ -20,6 +21,18 @@ #ifndef CONFIG_TQ_STEPPER_ENABLE #define CONFIG_TQ_STEPPER_ENABLE 1 #endif +#ifndef CONFIG_MULTIMODAL_AUDIO_PA_GPIO +#define CONFIG_MULTIMODAL_AUDIO_PA_GPIO -1 +#endif +#ifndef CONFIG_TQ_SCREEN_BACKLIGHT_GPIO +#define CONFIG_TQ_SCREEN_BACKLIGHT_GPIO -1 +#endif +#ifndef CONFIG_TQ_STEPPER_IN1_GPIO +#define CONFIG_TQ_STEPPER_IN1_GPIO -1 +#define CONFIG_TQ_STEPPER_IN2_GPIO -1 +#define CONFIG_TQ_STEPPER_IN3_GPIO -1 +#define CONFIG_TQ_STEPPER_IN4_GPIO -1 +#endif static const char *TAG = "platform_core"; static bool s_initialized; @@ -35,6 +48,106 @@ static const char *const s_known_nvs_namespaces[] = { "fortune", }; +bool platform_power_woke_from_deep_sleep(void) +{ + return esp_sleep_get_wakeup_cause() != ESP_SLEEP_WAKEUP_UNDEFINED; +} + +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); +} + +static void platform_power_set_board_outputs_held(bool held) +{ + const int output_gpios[] = { + CONFIG_MULTIMODAL_AUDIO_PA_GPIO, + CONFIG_TQ_SCREEN_BACKLIGHT_GPIO, + CONFIG_TQ_STEPPER_IN1_GPIO, + CONFIG_TQ_STEPPER_IN2_GPIO, + CONFIG_TQ_STEPPER_IN3_GPIO, + CONFIG_TQ_STEPPER_IN4_GPIO, + }; + for (size_t i = 0; i < sizeof(output_gpios) / sizeof(output_gpios[0]); ++i) { + int gpio = output_gpios[i]; + if (gpio < 0 || gpio >= GPIO_NUM_MAX) { + continue; + } + if (held) { + platform_power_set_output_low_and_hold(gpio); + } else { + (void)gpio_hold_dis((gpio_num_t)gpio); + } + } + if (held) { + gpio_deep_sleep_hold_en(); + } else { + gpio_deep_sleep_hold_dis(); + } +} + +void platform_power_release_deep_sleep_holds(void) +{ + platform_power_set_board_outputs_held(false); +} + +esp_err_t platform_power_enter_deep_sleep(int charger_gpio, int button_gpio) +{ + uint64_t wake_mask = 0U; + const int wake_gpios[] = {charger_gpio, button_gpio}; + + for (size_t i = 0; i < sizeof(wake_gpios) / sizeof(wake_gpios[0]); ++i) { + int gpio = wake_gpios[i]; + if (gpio < 0) { + continue; + } + if (gpio >= GPIO_NUM_MAX || + !esp_sleep_is_valid_wakeup_gpio((gpio_num_t)gpio)) { + ESP_LOGE(TAG, "Invalid deep-sleep wake GPIO: %d", gpio); + return ESP_ERR_INVALID_ARG; + } + + gpio_config_t input_config = { + .pin_bit_mask = 1ULL << (uint32_t)gpio, + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_ENABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + esp_err_t gpio_err = gpio_config(&input_config); + if (gpio_err != ESP_OK) { + return gpio_err; + } + wake_mask |= 1ULL << (uint32_t)gpio; + } + + if (wake_mask == 0U) { + ESP_LOGE(TAG, "Deep sleep requires at least one wake GPIO"); + return ESP_ERR_INVALID_STATE; + } + + 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; + } + + platform_power_set_board_outputs_held(true); + + ESP_LOGW(TAG, + "Entering deep sleep wake_mask=0x%llx", + (unsigned long long)wake_mask); + esp_deep_sleep_start(); + return ESP_ERR_INVALID_STATE; +} + static esp_err_t platform_gpio_isr_service_init(void) { if (s_gpio_isr_service_installed) { diff --git a/docs/battery-management.md b/docs/battery-management.md index db6ce5c..871af92 100644 --- a/docs/battery-management.md +++ b/docs/battery-management.md @@ -50,15 +50,33 @@ The direct LCD renderer composites the transparent `charging.png` asset in the top-right corner whenever GPIO10 reports active-low charging. It composites `lowbat.png` at the bottom center after the domain level enters low or critical state. Both overlays may be visible together, and QR-code presentations omit -them to preserve scan reliability. Overlay state changes do not trigger any -shutdown, sleep, prompt, or actuator policy. +them to preserve scan reliability. -## Deferred Policy +## Controlled Critical-Battery Shutdown -Critical status currently produces a state-transition log only. The reserved -shutdown hook returns `ESP_ERR_NOT_SUPPORTED`; it does not stop audio or the -stepper, enter deep sleep, or release board power. Implement wake sources and -the product shutdown sequence before enabling any automatic critical action. +When controlled shutdown is enabled, an unplugged battery at or below both the +configured critical percentage and 3450 mV must produce three valid samples. +An emergency raw voltage at or below 3350 mV requires two samples and is not +blocked by the high-load suppression window. Either path latches a single +shutdown sequence. + +The sequence stops interaction sources, OTA, multimodal transport, active +playback, and the stepper before synchronously playing +`shutdown_power_empty.opus`. The prompt is mandatory: a failed or incomplete +playback is retried and deep sleep is entered only after playback completion. +The codec/PA, LCD backlight, Wi-Fi, and stepper outputs are then disabled. PA, +backlight, and stepper GPIOs are held low through deep sleep. A small NVS record +stores the shutdown reason, voltage, and percentage on a best-effort basis. + +GPIO10 is configured as the default active-low wake source. The optional +active-low button wake GPIO defaults to `-1` (disabled). After deep-sleep wake, +normal boot continues only while charging or when battery voltage is at least +3550 mV. Otherwise the device immediately returns to deep sleep before audio, +LCD, Wi-Fi, or server startup. + +This is a low-power software shutdown, not physical board power removal, +because the board power-hold GPIO is disabled. The fixed battery divider and +any board-level always-on loads continue to draw current. ## Bench Calibration diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index b08c7de..ec0b0ae 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -1039,6 +1039,40 @@ menu "Example Configuration" range 1 100 depends on TQ_BATTERY_ENABLE + config TQ_BATTERY_CONTROLLED_SHUTDOWN_ENABLE + bool "Enable controlled shutdown at critically low battery" + default y + depends on TQ_BATTERY_ENABLE + + config TQ_BATTERY_SHUTDOWN_VOLTAGE_MV + int "Controlled shutdown voltage threshold (mV)" + default 3450 + range 3000 4200 + depends on TQ_BATTERY_CONTROLLED_SHUTDOWN_ENABLE + + config TQ_BATTERY_EMERGENCY_VOLTAGE_MV + int "Emergency shutdown voltage threshold (mV)" + default 3350 + range 3000 4200 + depends on TQ_BATTERY_CONTROLLED_SHUTDOWN_ENABLE + + config TQ_BATTERY_WAKE_RECOVERY_VOLTAGE_MV + int "Minimum voltage after deep-sleep wake (mV)" + default 3550 + range 3000 4200 + depends on TQ_BATTERY_CONTROLLED_SHUTDOWN_ENABLE + + config TQ_BATTERY_SHUTDOWN_WAKE_BUTTON_GPIO + int "Optional active-low deep-sleep wake button GPIO (-1 to disable)" + default -1 + range -1 48 + depends on TQ_BATTERY_CONTROLLED_SHUTDOWN_ENABLE + + config TQ_BATTERY_SHUTDOWN_PROMPT_FILE + string "Critical-battery shutdown prompt file" + default "/littlefs/audio_prompts/shutdown_power_empty.opus" + depends on TQ_BATTERY_CONTROLLED_SHUTDOWN_ENABLE + endmenu menu "Wi-Fi Provisioning" diff --git a/sdkconfig.defaults b/sdkconfig.defaults index ae63977..f37e7be 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -341,4 +341,10 @@ CONFIG_TQ_BATTERY_LOW_ENTER_PERCENT=20 CONFIG_TQ_BATTERY_LOW_EXIT_PERCENT=25 CONFIG_TQ_BATTERY_CRITICAL_ENTER_PERCENT=5 CONFIG_TQ_BATTERY_CRITICAL_EXIT_PERCENT=10 +CONFIG_TQ_BATTERY_CONTROLLED_SHUTDOWN_ENABLE=y +CONFIG_TQ_BATTERY_SHUTDOWN_VOLTAGE_MV=3450 +CONFIG_TQ_BATTERY_EMERGENCY_VOLTAGE_MV=3350 +CONFIG_TQ_BATTERY_WAKE_RECOVERY_VOLTAGE_MV=3550 +CONFIG_TQ_BATTERY_SHUTDOWN_WAKE_BUTTON_GPIO=-1 +CONFIG_TQ_BATTERY_SHUTDOWN_PROMPT_FILE="/littlefs/audio_prompts/shutdown_power_empty.opus" CONFIG_MULTIMODAL_AUDIO_PA_GPIO=11