diff --git a/AGENTS.md b/AGENTS.md index 0cc4bfe..41797a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,7 @@ The following matrix is authoritative for all new and refactored cross-layer con | Connectivity semantic prompt IDs/events (connecting/success/fail/config) | `domain` | `components/domain/include/domain.h` | `platform` must not expose duplicated semantic enums in public facade; map semantics in adapters. | | Mini-program Wi-Fi provisioning semantics (TalkingQ BLE discovery/GATT schema, credential events, status/device-info payloads) | `domain` | `components/domain/include/domain.h` | `platform` exposes generic BLE GATT server primitives only; `control_plane` owns Wi-Fi connection policy and worker lifecycle. | | Hardware/runtime primitive descriptors (GPIO digital input config, low-level actuator primitives, storage identity/blob KV APIs, wall-clock sync API) | `platform` | `components/platform/include/platform.h` | Upper layers call through owner facade or owner-backed adapters; no semantic redefinition in non-owner public headers. | +| Battery sensing and battery-state policy (`platform_battery_*`, `domain_battery_*`) | `domain` for SOC/level semantics; `platform` for ADC, charger GPIO, and load-quality primitives | `components/domain/include/domain.h` for semantic state; `components/platform/include/platform.h` for hardware samples | `control_plane` consumes domain status only; platform load tracking stays internal and upper layers must not infer SOC from raw ADC values. | | LAN HTTP transport contract (bounded request/response structs, handler delegate, server lifecycle/state) | `platform` | `components/platform/include/platform.h` | `domain` owns route semantics and maps them in implementation; upper layers must not expose platform HTTP types. | | Multimodal session transport contract exposed to orchestration (connected/disconnected/error/text/binary event family, audio uplink callback family) | `domain` | `components/domain/include/domain.h` | `platform` internal transport types stay internal; cross-layer public contract is declared once at `domain` facade. | | Device-auth persistence port (opaque auth key/state record slots and legacy migration targets) | `domain` | `components/domain/include/domain.h` | `control_plane` owns serialized auth record schemas and consumes typed auth slots through `domain`; `platform` remains an opaque blob KV owner only. | 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 5cc7e25..e6294a4 100644 --- a/components/app_composition/src/runtime/app_composition_default_runtime.c +++ b/components/app_composition/src/runtime/app_composition_default_runtime.c @@ -68,7 +68,7 @@ static esp_err_t app_composition_run_plan_a_boot_sequence(void) { app_composition_log_stage(APP_COMPOSITION_BOOT_STAGE_POWER_HOLD, "power_hold", - "already_asserted_by_platform_core"); + "configured_or_disabled_by_platform_core"); app_composition_log_stage(APP_COMPOSITION_BOOT_STAGE_STORAGE_PREPARE, "storage_prepare", @@ -139,6 +139,13 @@ static esp_err_t app_composition_run_plan_a_boot_sequence(void) return err; } + esp_err_t battery_err = control_plane_battery_service_start(); + if (battery_err != ESP_OK && battery_err != ESP_ERR_NOT_SUPPORTED) { + ESP_LOGW(TAG, + "Early battery service start failed, continue boot: %s", + esp_err_to_name(battery_err)); + } + #if CONFIG_TQ_FACTORY_CLI_ENABLE err = control_plane_factory_cli_start(); if (err != ESP_OK) { diff --git a/components/control_plane/CMakeLists.txt b/components/control_plane/CMakeLists.txt index c5c72c6..15b00b8 100644 --- a/components/control_plane/CMakeLists.txt +++ b/components/control_plane/CMakeLists.txt @@ -18,6 +18,7 @@ idf_component_register( "src/runtime/control_plane_runtime_workers.c" "src/runtime/control_plane_ota.c" "src/runtime/control_plane_factory_cli.c" + "src/runtime/control_plane_battery_service.c" INCLUDE_DIRS "include" PRIV_INCLUDE_DIRS diff --git a/components/control_plane/include/control_plane.h b/components/control_plane/include/control_plane.h index 6f52abf..c60e500 100644 --- a/components/control_plane/include/control_plane.h +++ b/components/control_plane/include/control_plane.h @@ -88,6 +88,15 @@ typedef struct { esp_err_t control_plane_init(void); +/** + * @brief Start battery sensing and charge-status presentation. + * + * Safe to call repeatedly. This service is intentionally started before + * connectivity bootstrap so battery and charger indications do not depend on + * Wi-Fi, provisioning, or server readiness. + */ +esp_err_t control_plane_battery_service_start(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 e1f472d..e15828f 100644 --- a/components/control_plane/internal/control_plane_internal.h +++ b/components/control_plane/internal/control_plane_internal.h @@ -147,6 +147,8 @@ bool control_plane_voice_turn_is_idle(void); bool control_plane_ota_voice_services_allowed(void); bool control_plane_runtime_is_stopping(void); +esp_err_t control_plane_battery_service_stop(int timeout_ms); + esp_err_t control_plane_start_system_event_task(void); void control_plane_stop_system_event_task(void); esp_err_t control_plane_post_system_event(const control_plane_system_event_t *event, uint32_t timeout_ms); diff --git a/components/control_plane/src/runtime/control_plane_battery_service.c b/components/control_plane/src/runtime/control_plane_battery_service.c new file mode 100644 index 0000000..26342ac --- /dev/null +++ b/components/control_plane/src/runtime/control_plane_battery_service.c @@ -0,0 +1,426 @@ +/* + * 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_LOW_PROMPT_FILE "/littlefs/audio_prompts/battery_low_20.opus" +#define BATTERY_FULL_PROMPT_FILE "/littlefs/audio_prompts/charge_complete.opus" + +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 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_install_isr_service(0); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + return err; + } + + 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 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 control_plane_battery_critical_hook( + const domain_battery_status_t *status) +{ + (void)status; + /* + * Reserved for a future product decision. Do not stop actuators, enter + * sleep, or cut board power in this release. + */ +} + +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); + } + 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) { + 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; + } + } + } + } + + uint32_t delay_ms; + if (status_err == ESP_OK && status.measurement_suppressed) { + delay_ms = BATTERY_SUPPRESSED_RETRY_MS; + } else if (full_hint_candidate) { + delay_ms = BATTERY_FULL_CONFIRM_RETRY_MS; + } 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_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; + 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; +} + +#else + +esp_err_t control_plane_battery_service_start(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; +} + +#endif 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 4b06df5..8590655 100644 --- a/components/control_plane/src/runtime/control_plane_runtime_workers.c +++ b/components/control_plane/src/runtime/control_plane_runtime_workers.c @@ -2264,6 +2264,11 @@ esp_err_t control_plane_start_runtime_workers(void) return queue_err; } + esp_err_t battery_err = control_plane_battery_service_start(); + if (battery_err != ESP_OK && battery_err != ESP_ERR_NOT_SUPPORTED) { + ESP_LOGW(TAG, "Battery service start failed: %s", esp_err_to_name(battery_err)); + } + if (control_plane_get_task_handle(&s_model_cmd_task) == NULL) { esp_err_t task_err = control_plane_create_task_psram_first(control_plane_model_command_task, "cp_model_cmd", @@ -2412,6 +2417,7 @@ void control_plane_stop_event_observer_runtime(void) void control_plane_stop_runtime_workers(void) { control_plane_set_runtime_stop_requested(true); + (void)control_plane_battery_service_stop(CONTROL_PLANE_TASK_STOP_WAIT_MS); (void)control_plane_stop_ota_service(CONFIG_TQ_LOCAL_WAKE_STOP_WAIT_MS); (void)control_plane_stop_mini_program_provisioning( CONTROL_PLANE_TASK_STOP_WAIT_MS); diff --git a/components/domain/CMakeLists.txt b/components/domain/CMakeLists.txt index 3a800d8..6040b43 100644 --- a/components/domain/CMakeLists.txt +++ b/components/domain/CMakeLists.txt @@ -7,6 +7,7 @@ idf_component_register( "src/calibration/domain_calibration_service.c" "src/fortune/domain_fortune_store.c" "src/fortune/domain_user_config_server.c" + "src/battery/domain_battery_service.c" "src/runtime/domain_runtime_adapters.c" "src/runtime/domain_ota.c" "src/runtime/domain_local_command.c" @@ -17,6 +18,8 @@ idf_component_register( "internal" PRIV_REQUIRES platform + esp_timer + freertos esp-sr log json diff --git a/components/domain/include/domain.h b/components/domain/include/domain.h index 17d3c4a..aa17fd7 100644 --- a/components/domain/include/domain.h +++ b/components/domain/include/domain.h @@ -29,6 +29,47 @@ typedef struct { int settle_delay_ms; } domain_direction_calibration_t; +typedef enum { + DOMAIN_BATTERY_CHARGE_UNKNOWN = 0, + DOMAIN_BATTERY_CHARGE_NOT_CHARGING, + DOMAIN_BATTERY_CHARGE_CHARGING, +} domain_battery_charge_state_t; + +typedef enum { + DOMAIN_BATTERY_LEVEL_UNKNOWN = 0, + DOMAIN_BATTERY_LEVEL_NORMAL, + DOMAIN_BATTERY_LEVEL_LOW, + DOMAIN_BATTERY_LEVEL_CRITICAL, +} domain_battery_level_state_t; + +typedef struct { + int voltage_mv; + int latest_adc_mv; + int latest_sample_mv; + uint8_t percentage; + domain_battery_charge_state_t charge_state; + domain_battery_level_state_t level_state; + bool valid; + bool latest_sample_valid; + bool measurement_suppressed; + bool high_load_active; + bool cooldown_active; + uint32_t consecutive_failures; + int64_t last_valid_sample_time_ms; +} domain_battery_status_t; + +/** Initialize the battery measurement domain. Idempotent and thread-safe. */ +esp_err_t domain_battery_init(void); + +/** Acquire and process one battery sample without applying product policy. */ +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. */ +esp_err_t domain_battery_prepare_shutdown(void); + typedef enum { DOMAIN_WIFI_PROMPT_WIFI_SUCCESS = 0, DOMAIN_WIFI_PROMPT_AFTER_WIFI_SUCCESS, @@ -560,6 +601,10 @@ void domain_runtime_start_lcd_render_loop(void); * values wait up to the bound before lower layers apply deterministic fallback. */ esp_err_t domain_runtime_stop_lcd_render_loop(int timeout_ms); + +/** Map the domain battery snapshot to persistent LCD status overlays. */ +esp_err_t domain_runtime_update_battery_overlay(const domain_battery_status_t *status); + esp_err_t domain_runtime_request_expression(const domain_expression_request_t *request); /** diff --git a/components/domain/src/battery/domain_battery_service.c b/components/domain/src/battery/domain_battery_service.c new file mode 100644 index 0000000..432f6f0 --- /dev/null +++ b/components/domain/src/battery/domain_battery_service.c @@ -0,0 +1,361 @@ +/* + * SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "domain.h" + +#include +#include + +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "platform.h" +#include "sdkconfig.h" + +#if CONFIG_TQ_BATTERY_ENABLE + +#if CONFIG_TQ_BATTERY_LOW_EXIT_PERCENT <= CONFIG_TQ_BATTERY_LOW_ENTER_PERCENT +#error "Battery low recovery threshold must exceed the entry threshold" +#endif + +#if CONFIG_TQ_BATTERY_CRITICAL_EXIT_PERCENT <= CONFIG_TQ_BATTERY_CRITICAL_ENTER_PERCENT +#error "Battery critical recovery threshold must exceed the entry threshold" +#endif + +#if CONFIG_TQ_BATTERY_CRITICAL_ENTER_PERCENT >= CONFIG_TQ_BATTERY_LOW_ENTER_PERCENT +#error "Battery critical entry threshold must be below the low entry threshold" +#endif + +#define BATTERY_STATE_CONFIRM_SAMPLES 3U +#define BATTERY_MAX_ACCEPTED_STEP_MV 300 +#define BATTERY_JUMP_MATCH_WINDOW_MV 100 +#define BATTERY_FILTER_OLD_WEIGHT 8 +#define BATTERY_FILTER_NEW_WEIGHT 2 + +typedef struct { + int voltage_mv; + uint8_t percentage; +} battery_curve_point_t; + +static const battery_curve_point_t s_discharge_curve[] = { + {3300, 0}, + {3400, 2}, + {3600, 8}, + {3700, 18}, + {3800, 35}, + {3900, 55}, + {4000, 75}, + {4100, 90}, + {4200, 100}, +}; + +static StaticSemaphore_t s_lock_buffer; +static SemaphoreHandle_t s_lock; +static portMUX_TYPE s_lock_init_guard = portMUX_INITIALIZER_UNLOCKED; +static domain_battery_status_t s_status; +static bool s_initialized; +static bool s_filter_initialized; +static int s_filtered_mv; +static int s_jump_candidate_mv; +static uint8_t s_jump_candidate_count; +static uint8_t s_low_count; +static uint8_t s_critical_count; + +static bool ensure_lock(void) +{ + if (s_lock != NULL) { + return true; + } + portENTER_CRITICAL(&s_lock_init_guard); + if (s_lock == NULL) { + s_lock = xSemaphoreCreateMutexStatic(&s_lock_buffer); + } + portEXIT_CRITICAL(&s_lock_init_guard); + return s_lock != NULL; +} + +static bool lock_state(void) +{ + return ensure_lock() && xSemaphoreTake(s_lock, pdMS_TO_TICKS(100)) == pdTRUE; +} + +static void unlock_state(void) +{ + (void)xSemaphoreGive(s_lock); +} + +static domain_battery_charge_state_t map_charge_state( + platform_battery_charge_state_t state) +{ + switch (state) { + case PLATFORM_BATTERY_CHARGE_CHARGING: + return DOMAIN_BATTERY_CHARGE_CHARGING; + case PLATFORM_BATTERY_CHARGE_NOT_CHARGING: + return DOMAIN_BATTERY_CHARGE_NOT_CHARGING; + default: + return DOMAIN_BATTERY_CHARGE_UNKNOWN; + } +} + +static uint8_t voltage_to_percentage(int voltage_mv) +{ + if (voltage_mv <= s_discharge_curve[0].voltage_mv) { + return s_discharge_curve[0].percentage; + } + + const size_t count = sizeof(s_discharge_curve) / sizeof(s_discharge_curve[0]); + for (size_t i = 1; i < count; ++i) { + if (voltage_mv <= s_discharge_curve[i].voltage_mv) { + int lower_mv = s_discharge_curve[i - 1U].voltage_mv; + int upper_mv = s_discharge_curve[i].voltage_mv; + int lower_pct = s_discharge_curve[i - 1U].percentage; + int upper_pct = s_discharge_curve[i].percentage; + return (uint8_t)(lower_pct + + (voltage_mv - lower_mv) * (upper_pct - lower_pct) / + (upper_mv - lower_mv)); + } + } + return 100U; +} + +static bool accept_voltage_step(int voltage_mv) +{ + if (!s_filter_initialized || abs(voltage_mv - s_filtered_mv) <= BATTERY_MAX_ACCEPTED_STEP_MV) { + s_jump_candidate_count = 0U; + return true; + } + + if (s_jump_candidate_count == 0U || + abs(voltage_mv - s_jump_candidate_mv) > BATTERY_JUMP_MATCH_WINDOW_MV) { + s_jump_candidate_mv = voltage_mv; + s_jump_candidate_count = 1U; + return false; + } + + if (s_jump_candidate_count < BATTERY_STATE_CONFIRM_SAMPLES) { + ++s_jump_candidate_count; + } + if (s_jump_candidate_count < BATTERY_STATE_CONFIRM_SAMPLES) { + return false; + } + + s_jump_candidate_count = 0U; + return true; +} + +static void update_level_state(uint8_t percentage, + domain_battery_charge_state_t charge_state) +{ + if (charge_state == DOMAIN_BATTERY_CHARGE_CHARGING && + s_status.level_state == DOMAIN_BATTERY_LEVEL_CRITICAL) { + s_status.level_state = DOMAIN_BATTERY_LEVEL_LOW; + s_critical_count = 0U; + } + + switch (s_status.level_state) { + case DOMAIN_BATTERY_LEVEL_UNKNOWN: + case DOMAIN_BATTERY_LEVEL_NORMAL: + s_status.level_state = DOMAIN_BATTERY_LEVEL_NORMAL; + if (percentage <= CONFIG_TQ_BATTERY_LOW_ENTER_PERCENT) { + if (++s_low_count >= BATTERY_STATE_CONFIRM_SAMPLES) { + s_status.level_state = DOMAIN_BATTERY_LEVEL_LOW; + s_low_count = 0U; + } + } else { + s_low_count = 0U; + } + break; + + case DOMAIN_BATTERY_LEVEL_LOW: + if (percentage >= CONFIG_TQ_BATTERY_LOW_EXIT_PERCENT) { + s_status.level_state = DOMAIN_BATTERY_LEVEL_NORMAL; + s_critical_count = 0U; + } else if (charge_state == DOMAIN_BATTERY_CHARGE_CHARGING) { + s_critical_count = 0U; + } else if (percentage <= CONFIG_TQ_BATTERY_CRITICAL_ENTER_PERCENT) { + if (++s_critical_count >= BATTERY_STATE_CONFIRM_SAMPLES) { + s_status.level_state = DOMAIN_BATTERY_LEVEL_CRITICAL; + s_critical_count = 0U; + } + } else { + s_critical_count = 0U; + } + break; + + case DOMAIN_BATTERY_LEVEL_CRITICAL: + if (charge_state == DOMAIN_BATTERY_CHARGE_CHARGING || + percentage >= CONFIG_TQ_BATTERY_CRITICAL_EXIT_PERCENT) { + s_status.level_state = percentage >= CONFIG_TQ_BATTERY_LOW_EXIT_PERCENT + ? DOMAIN_BATTERY_LEVEL_NORMAL + : DOMAIN_BATTERY_LEVEL_LOW; + } + break; + } +} + +esp_err_t domain_battery_init(void) +{ + if (!lock_state()) { + return ESP_ERR_TIMEOUT; + } + if (s_initialized) { + unlock_state(); + return ESP_OK; + } + + esp_err_t err = platform_battery_init(); + if (err != ESP_OK) { + unlock_state(); + return err; + } + + memset(&s_status, 0, sizeof(s_status)); + s_status.charge_state = DOMAIN_BATTERY_CHARGE_UNKNOWN; + s_status.level_state = DOMAIN_BATTERY_LEVEL_UNKNOWN; + s_initialized = true; + unlock_state(); + return ESP_OK; +} + +esp_err_t domain_battery_sample(void) +{ + esp_err_t err = domain_battery_init(); + if (err != ESP_OK) { + return err; + } + + platform_battery_sample_t sample = {0}; + err = platform_battery_read_sample(&sample); + if (!lock_state()) { + return ESP_ERR_TIMEOUT; + } + + if (err != ESP_OK) { + s_status.latest_sample_valid = false; + if (s_status.consecutive_failures < UINT32_MAX) { + ++s_status.consecutive_failures; + } + if (s_status.consecutive_failures >= BATTERY_STATE_CONFIRM_SAMPLES) { + s_status.valid = false; + } + unlock_state(); + return err; + } + + s_status.latest_adc_mv = sample.adc_voltage_mv; + s_status.latest_sample_mv = sample.voltage_mv; + s_status.latest_sample_valid = sample.voltage_valid; + s_status.charge_state = map_charge_state(sample.charge_state); + s_status.high_load_active = sample.high_load_active; + s_status.cooldown_active = sample.cooldown_active; + s_status.measurement_suppressed = sample.high_load_active || sample.cooldown_active; + if (s_status.measurement_suppressed) { + unlock_state(); + return ESP_OK; + } + + if (!sample.voltage_valid) { + if (s_status.consecutive_failures < UINT32_MAX) { + ++s_status.consecutive_failures; + } + if (s_status.consecutive_failures >= BATTERY_STATE_CONFIRM_SAMPLES) { + s_status.valid = false; + } + unlock_state(); + return ESP_ERR_INVALID_RESPONSE; + } + + if (!accept_voltage_step(sample.voltage_mv)) { + unlock_state(); + return ESP_OK; + } + + if (!s_filter_initialized) { + s_filtered_mv = sample.voltage_mv; + s_filter_initialized = true; + } else { + s_filtered_mv = (s_filtered_mv * BATTERY_FILTER_OLD_WEIGHT + + sample.voltage_mv * BATTERY_FILTER_NEW_WEIGHT) / + (BATTERY_FILTER_OLD_WEIGHT + BATTERY_FILTER_NEW_WEIGHT); + } + + uint8_t percentage = voltage_to_percentage(s_filtered_mv); + if (s_status.valid) { + if (s_status.charge_state == DOMAIN_BATTERY_CHARGE_CHARGING && + percentage < s_status.percentage) { + percentage = s_status.percentage; + } else if (s_status.charge_state != DOMAIN_BATTERY_CHARGE_CHARGING && + percentage > s_status.percentage) { + percentage = s_status.percentage; + } + + if (percentage > s_status.percentage + 2U) { + percentage = s_status.percentage + 2U; + } else if (s_status.percentage > percentage + 2U) { + percentage = s_status.percentage - 2U; + } + } + + s_status.voltage_mv = s_filtered_mv; + s_status.percentage = percentage; + s_status.valid = true; + s_status.measurement_suppressed = false; + s_status.consecutive_failures = 0U; + s_status.last_valid_sample_time_ms = esp_timer_get_time() / 1000; + update_level_state(percentage, s_status.charge_state); + unlock_state(); + return ESP_OK; +} + +esp_err_t domain_battery_get_status(domain_battery_status_t *out_status) +{ + if (out_status == NULL) { + return ESP_ERR_INVALID_ARG; + } + if (!lock_state()) { + return ESP_ERR_TIMEOUT; + } + if (!s_initialized) { + unlock_state(); + return ESP_ERR_INVALID_STATE; + } + *out_status = s_status; + unlock_state(); + return ESP_OK; +} + +esp_err_t domain_battery_prepare_shutdown(void) +{ + /* Reserved for a future board-specific sleep or power-cut sequence. */ + return ESP_ERR_NOT_SUPPORTED; +} + +#else + +esp_err_t domain_battery_init(void) +{ + return ESP_ERR_NOT_SUPPORTED; +} + +esp_err_t domain_battery_sample(void) +{ + return ESP_ERR_NOT_SUPPORTED; +} + +esp_err_t domain_battery_get_status(domain_battery_status_t *out_status) +{ + if (out_status != NULL) { + memset(out_status, 0, sizeof(*out_status)); + } + return ESP_ERR_NOT_SUPPORTED; +} + +esp_err_t domain_battery_prepare_shutdown(void) +{ + return ESP_ERR_NOT_SUPPORTED; +} + +#endif diff --git a/components/domain/src/runtime/domain_runtime_adapters.c b/components/domain/src/runtime/domain_runtime_adapters.c index 61ee3bb..d687c06 100644 --- a/components/domain/src/runtime/domain_runtime_adapters.c +++ b/components/domain/src/runtime/domain_runtime_adapters.c @@ -1318,6 +1318,22 @@ esp_err_t domain_runtime_stop_lcd_render_loop(int timeout_ms) return platform_stop_lcd_render_loop(timeout_ms); } +esp_err_t domain_runtime_update_battery_overlay(const domain_battery_status_t *status) +{ + if (status == NULL) { + return ESP_ERR_INVALID_ARG; + } + + const platform_lcd_status_overlay_t overlay = { + .low_battery_visible = status->valid && + (status->level_state == DOMAIN_BATTERY_LEVEL_LOW || + status->level_state == DOMAIN_BATTERY_LEVEL_CRITICAL), + .charging_visible = + status->charge_state == DOMAIN_BATTERY_CHARGE_CHARGING, + }; + return platform_lcd_set_status_overlay(&overlay); +} + static const domain_expression_catalog_entry_t *domain_find_expression_entry(domain_expression_id_t id) { for (size_t i = 0; i < sizeof(s_expression_catalog) / sizeof(s_expression_catalog[0]); ++i) { diff --git a/components/platform/CMakeLists.txt b/components/platform/CMakeLists.txt index 8dcfcc6..da7a067 100644 --- a/components/platform/CMakeLists.txt +++ b/components/platform/CMakeLists.txt @@ -11,6 +11,7 @@ set(platform_srcs "src/runtime/platform_multimodal_runtime.c" "src/runtime/platform_lcd_runtime.c" "src/platform_stepper_driver.c" + "src/platform_battery_driver.c" "src/platform_digital_input_driver.c" "src/platform_io_effects.c" "src/lcd_direct.c" @@ -30,6 +31,7 @@ set(platform_priv_requires cxx driver esp_audio_codec + esp_adc esp_codec_dev esp_driver_gpio esp_driver_i2c @@ -140,6 +142,8 @@ set(platform_lcd_png_assets icon_huo0 icon_tu0 icon_jin0 + charging + lowbat qrcode ) set(platform_lcd_png_asset_files) diff --git a/components/platform/assets/lcd/charging.png b/components/platform/assets/lcd/charging.png new file mode 100644 index 0000000..b66532e Binary files /dev/null and b/components/platform/assets/lcd/charging.png differ diff --git a/components/platform/assets/lcd/lowbat.png b/components/platform/assets/lcd/lowbat.png new file mode 100644 index 0000000..27ce316 Binary files /dev/null and b/components/platform/assets/lcd/lowbat.png differ diff --git a/components/platform/include/platform.h b/components/platform/include/platform.h index 391c57f..e7455d0 100644 --- a/components/platform/include/platform.h +++ b/components/platform/include/platform.h @@ -302,6 +302,33 @@ esp_err_t platform_stepper_rotate_angle_with_accel_timeout(float angle, int target_delay_us, int timeout_ms); void platform_stepper_power_off(void); + +typedef enum { + PLATFORM_BATTERY_CHARGE_UNKNOWN = 0, + PLATFORM_BATTERY_CHARGE_NOT_CHARGING, + PLATFORM_BATTERY_CHARGE_CHARGING, +} platform_battery_charge_state_t; + +typedef struct { + int adc_voltage_mv; + int voltage_mv; + platform_battery_charge_state_t charge_state; + bool voltage_valid; + bool high_load_active; + bool cooldown_active; +} platform_battery_sample_t; + +/** Initialize the board battery ADC and TP4057 status input. Idempotent. */ +esp_err_t platform_battery_init(void); + +/** + * Read one trimmed-mean battery voltage sample and a load-quality snapshot. + * A successful call can still return voltage_valid=false for an implausible + * electrical reading; callers must not interpret such a sample as an empty + * battery. + */ +esp_err_t platform_battery_read_sample(platform_battery_sample_t *out_sample); + esp_err_t platform_configure_digital_input(const platform_digital_input_config_t *config); bool platform_is_digital_input_active(const platform_digital_input_config_t *config); @@ -480,6 +507,19 @@ typedef struct { size_t step_count; } platform_lcd_presentation_request_t; +typedef struct { + bool low_battery_visible; + bool charging_visible; +} platform_lcd_status_overlay_t; + +/** + * @brief Set persistent LCD status overlays composited over presentation frames. + * + * The state is copied synchronously and remains active until replaced. The + * render task owns all PNG decoding and panel access. + */ +esp_err_t platform_lcd_set_status_overlay(const platform_lcd_status_overlay_t *overlay); + /** * @brief Request a LCD presentation playback. * diff --git a/components/platform/internal/lcd_direct.h b/components/platform/internal/lcd_direct.h index 9b75a3e..b6cfae7 100644 --- a/components/platform/internal/lcd_direct.h +++ b/components/platform/internal/lcd_direct.h @@ -12,6 +12,7 @@ extern "C" { esp_err_t lcd_direct_init_device(void); void lcd_direct_start_render_loop(void); esp_err_t lcd_direct_stop_render_loop(int timeout_ms); +esp_err_t lcd_direct_set_status_overlay(const platform_lcd_status_overlay_t *overlay); esp_err_t lcd_direct_request_presentation(const platform_lcd_presentation_request_t *request); esp_err_t lcd_direct_preheat_presentation(const platform_lcd_presentation_request_t *request); uint32_t lcd_direct_presentation_completion_count(void); diff --git a/components/platform/internal/lcd_direct_assets.h b/components/platform/internal/lcd_direct_assets.h index 35ff123..459165f 100644 --- a/components/platform/internal/lcd_direct_assets.h +++ b/components/platform/internal/lcd_direct_assets.h @@ -23,6 +23,8 @@ typedef struct { #define LCD_DIRECT_NO_ICON 0xFFFFU #define LCD_DIRECT_DEFAULT_ICON_COUNT 5U +#define LCD_DIRECT_ICON_CHARGING 5U +#define LCD_DIRECT_ICON_LOW_BATTERY 6U const lcd_direct_image_t *lcd_direct_default_image(void); const lcd_direct_image_t *lcd_direct_asset_frame(const char *asset_dir, uint8_t frame_index); diff --git a/components/platform/internal/platform_battery_internal.h b/components/platform/internal/platform_battery_internal.h new file mode 100644 index 0000000..ed70499 --- /dev/null +++ b/components/platform/internal/platform_battery_internal.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +typedef enum { + PLATFORM_BATTERY_LOAD_AUDIO = 0, + PLATFORM_BATTERY_LOAD_STEPPER, + PLATFORM_BATTERY_LOAD_COUNT, +} platform_battery_load_source_t; + +void platform_battery_load_begin(platform_battery_load_source_t source); +void platform_battery_load_end(platform_battery_load_source_t source); diff --git a/components/platform/src/lcd_direct.c b/components/platform/src/lcd_direct.c index 5d2a630..380f966 100644 --- a/components/platform/src/lcd_direct.c +++ b/components/platform/src/lcd_direct.c @@ -46,8 +46,15 @@ #define LCD_DIRECT_PRESENTATION_WAIT_POLL_MS 20U #define LCD_DIRECT_TRANSFER_TIMEOUT_MS 100U #define LCD_DIRECT_PREHEAT_WAIT_TIMEOUT_MS 5000U -#define LCD_DIRECT_FRAME_CACHE_MAX 4U +#define LCD_DIRECT_FRAME_CACHE_MAX 6U #define LCD_DIRECT_PATH_MAX 96U +#define LCD_DIRECT_CHARGING_ICON_WIDTH 50U +#define LCD_DIRECT_CHARGING_ICON_X (LCD_DIRECT_WIDTH - LCD_DIRECT_CHARGING_ICON_WIDTH - 8U) +#define LCD_DIRECT_CHARGING_ICON_Y 8U +#define LCD_DIRECT_LOW_BATTERY_ICON_WIDTH 232U +#define LCD_DIRECT_LOW_BATTERY_ICON_HEIGHT 36U +#define LCD_DIRECT_LOW_BATTERY_ICON_X ((LCD_DIRECT_WIDTH - LCD_DIRECT_LOW_BATTERY_ICON_WIDTH) / 2U) +#define LCD_DIRECT_LOW_BATTERY_ICON_Y (LCD_DIRECT_HEIGHT - LCD_DIRECT_LOW_BATTERY_ICON_HEIGHT - 8U) #define LCD_DIRECT_LAST_FRAME_EXTRA_HOLD_MS 200U #define LCD_DIRECT_BASIC_FACE_FRAME_HOLD_MS 100U #define LCD_DIRECT_BASIC_FACE_LAST_FRAME_HOLD_MS 100U @@ -135,6 +142,7 @@ static bool s_render_loop_running; static bool s_stop_requested; static bool s_pending_valid; static lcd_direct_request_t s_pending; +static platform_lcd_status_overlay_t s_status_overlay; static bool s_preheat_pending_valid; static bool s_preheat_in_progress; static bool s_preheat_waiter_active; @@ -683,6 +691,38 @@ static void lcd_direct_release_prepared_icons(lcd_direct_prepared_icon_t *prepar } } +static size_t lcd_direct_append_status_icons(const lcd_direct_image_t *image, + lcd_direct_icon_draw_t *icons, + size_t icon_count, + size_t icon_capacity) +{ + if (image == NULL || image->path == NULL || icons == NULL || + icon_count >= icon_capacity || strstr(image->path, "/qrcode.png") != NULL) { + return icon_count; + } + + platform_lcd_status_overlay_t overlay = {0}; + portENTER_CRITICAL(&s_state_lock); + overlay = s_status_overlay; + portEXIT_CRITICAL(&s_state_lock); + + if (overlay.low_battery_visible && icon_count < icon_capacity) { + icons[icon_count++] = (lcd_direct_icon_draw_t) { + .icon_id = LCD_DIRECT_ICON_LOW_BATTERY, + .x = LCD_DIRECT_LOW_BATTERY_ICON_X, + .y = LCD_DIRECT_LOW_BATTERY_ICON_Y, + }; + } + if (overlay.charging_visible && icon_count < icon_capacity) { + icons[icon_count++] = (lcd_direct_icon_draw_t) { + .icon_id = LCD_DIRECT_ICON_CHARGING, + .x = LCD_DIRECT_CHARGING_ICON_X, + .y = LCD_DIRECT_CHARGING_ICON_Y, + }; + } + return icon_count; +} + static void lcd_direct_overlay_icons(uint16_t *pixel_buffer, uint16_t row_start, uint16_t row_count, @@ -766,9 +806,23 @@ static esp_err_t lcd_direct_draw_png_image(const lcd_direct_image_t *image, decoded->pinned = true; } + lcd_direct_icon_draw_t combined_icons[LCD_DIRECT_DEFAULT_ICON_COUNT] = {0}; + size_t combined_icon_count = icon_count; + if (combined_icon_count > LCD_DIRECT_DEFAULT_ICON_COUNT) { + combined_icon_count = LCD_DIRECT_DEFAULT_ICON_COUNT; + } + if (icons != NULL && combined_icon_count > 0U) { + memcpy(combined_icons, icons, combined_icon_count * sizeof(combined_icons[0])); + } + combined_icon_count = lcd_direct_append_status_icons( + image, + combined_icons, + combined_icon_count, + LCD_DIRECT_DEFAULT_ICON_COUNT); + lcd_direct_prepared_icon_t prepared_icons[LCD_DIRECT_DEFAULT_ICON_COUNT] = {0}; - size_t prepared_icon_count = lcd_direct_prepare_icons(icons, - icon_count, + size_t prepared_icon_count = lcd_direct_prepare_icons(combined_icons, + combined_icon_count, prepared_icons, sizeof(prepared_icons) / sizeof(prepared_icons[0])); esp_err_t err = ESP_OK; @@ -1424,6 +1478,33 @@ esp_err_t lcd_direct_stop_render_loop(int timeout_ms) return ESP_ERR_TIMEOUT; } +esp_err_t lcd_direct_set_status_overlay(const platform_lcd_status_overlay_t *overlay) +{ + if (overlay == NULL) { + return ESP_ERR_INVALID_ARG; + } + + bool changed = false; + TaskHandle_t render_task = NULL; + portENTER_CRITICAL(&s_state_lock); + changed = s_status_overlay.low_battery_visible != overlay->low_battery_visible || + s_status_overlay.charging_visible != overlay->charging_visible; + s_status_overlay = *overlay; + render_task = s_render_task; + portEXIT_CRITICAL(&s_state_lock); + + if (changed && render_task != NULL) { + xTaskNotifyGive(render_task); + } + if (changed) { + ESP_LOGI(TAG, + "LCD battery overlays low=%d charging=%d", + overlay->low_battery_visible, + overlay->charging_visible); + } + return ESP_OK; +} + esp_err_t lcd_direct_request_presentation(const platform_lcd_presentation_request_t *request) { if (!s_initialized || request == NULL) { diff --git a/components/platform/src/lcd_direct_assets.c b/components/platform/src/lcd_direct_assets.c index 0279b7a..fc11247 100644 --- a/components/platform/src/lcd_direct_assets.c +++ b/components/platform/src/lcd_direct_assets.c @@ -217,6 +217,12 @@ static const lcd_direct_icon_t s_wuxing_icons[] = { { .path = LCD_ASSET_BASE_PATH "icon_jin0.png", }, + { + .path = LCD_ASSET_BASE_PATH "charging.png", + }, + { + .path = LCD_ASSET_BASE_PATH "lowbat.png", + }, }; static const lcd_direct_image_t *lcd_direct_frame_from_array(const lcd_direct_image_t *frames, diff --git a/components/platform/src/platform_battery_driver.c b/components/platform/src/platform_battery_driver.c new file mode 100644 index 0000000..6e302b5 --- /dev/null +++ b/components/platform/src/platform_battery_driver.c @@ -0,0 +1,353 @@ +/* + * SPDX-FileCopyrightText: 2024-2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "platform.h" + +#include +#include +#include + +#include "driver/gpio.h" +#include "esp_adc/adc_cali.h" +#include "esp_adc/adc_cali_scheme.h" +#include "esp_adc/adc_oneshot.h" +#include "esp_log.h" +#include "esp_rom_sys.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/portmacro.h" +#include "freertos/semphr.h" +#include "platform_battery_internal.h" +#include "sdkconfig.h" + +#if CONFIG_TQ_BATTERY_ENABLE + +#define BATTERY_ADC_SAMPLE_COUNT 16 +#define BATTERY_ADC_TRIM_COUNT 4 +#define BATTERY_VALID_MIN_MV 2500 +#define BATTERY_VALID_MAX_MV 4500 +#define BATTERY_ADC_SAMPLE_DELAY_US 200 + +static const char *TAG = "platform_battery"; +static adc_oneshot_unit_handle_t s_adc_handle; +static adc_cali_handle_t s_cali_handle; +static adc_channel_t s_adc_channel; +static bool s_initialized; +static StaticSemaphore_t s_adc_lock_buffer; +static SemaphoreHandle_t s_adc_lock; +static portMUX_TYPE s_adc_lock_init_guard = portMUX_INITIALIZER_UNLOCKED; +static portMUX_TYPE s_state_lock = portMUX_INITIALIZER_UNLOCKED; +static uint16_t s_load_ref_count[PLATFORM_BATTERY_LOAD_COUNT]; +static int64_t s_cooldown_until_us; + +static bool lock_adc(void) +{ + if (s_adc_lock == NULL) { + portENTER_CRITICAL(&s_adc_lock_init_guard); + if (s_adc_lock == NULL) { + s_adc_lock = xSemaphoreCreateMutexStatic(&s_adc_lock_buffer); + } + portEXIT_CRITICAL(&s_adc_lock_init_guard); + } + return s_adc_lock != NULL && + xSemaphoreTake(s_adc_lock, pdMS_TO_TICKS(100)) == pdTRUE; +} + +static void unlock_adc(void) +{ + (void)xSemaphoreGive(s_adc_lock); +} + +static uint64_t gpio_mask(int gpio_num) +{ + return 1ULL << (unsigned)gpio_num; +} + +static void sort_ints(int *values, size_t count) +{ + for (size_t i = 1; i < count; ++i) { + int value = values[i]; + size_t j = i; + while (j > 0U && values[j - 1U] > value) { + values[j] = values[j - 1U]; + --j; + } + values[j] = value; + } +} + +static esp_err_t create_calibration(adc_unit_t unit, adc_channel_t channel) +{ +#if ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED + { + adc_cali_curve_fitting_config_t config = { + .unit_id = unit, + .chan = channel, + .atten = ADC_ATTEN_DB_12, + .bitwidth = ADC_BITWIDTH_DEFAULT, + }; + esp_err_t err = adc_cali_create_scheme_curve_fitting(&config, &s_cali_handle); + if (err == ESP_OK) { + return ESP_OK; + } + ESP_LOGW(TAG, "Curve-fitting ADC calibration unavailable: %s", esp_err_to_name(err)); + } +#endif + +#if ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED + { + adc_cali_line_fitting_config_t config = { + .unit_id = unit, + .atten = ADC_ATTEN_DB_12, + .bitwidth = ADC_BITWIDTH_DEFAULT, + .default_vref = 0, + }; + esp_err_t err = adc_cali_create_scheme_line_fitting(&config, &s_cali_handle); + if (err == ESP_OK) { + return ESP_OK; + } + ESP_LOGW(TAG, "Line-fitting ADC calibration unavailable: %s", esp_err_to_name(err)); + } +#endif + + return ESP_ERR_NOT_SUPPORTED; +} + +esp_err_t platform_battery_init(void) +{ + if (!lock_adc()) { + return ESP_ERR_TIMEOUT; + } + if (s_initialized) { + unlock_adc(); + return ESP_OK; + } + + if (CONFIG_TQ_BATTERY_CHARGE_GPIO >= GPIO_NUM_MAX) { + unlock_adc(); + return ESP_ERR_INVALID_ARG; + } + if (CONFIG_TQ_BATTERY_CHARGE_GPIO >= 0) { + gpio_config_t charge_config = { + .pin_bit_mask = gpio_mask(CONFIG_TQ_BATTERY_CHARGE_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(&charge_config); + if (gpio_err != ESP_OK) { + ESP_LOGE(TAG, "Configure TP4057 status GPIO failed: %s", esp_err_to_name(gpio_err)); + unlock_adc(); + return gpio_err; + } + } + + adc_unit_t unit = ADC_UNIT_1; + adc_channel_t channel = ADC_CHANNEL_0; + esp_err_t err = adc_oneshot_io_to_channel(CONFIG_TQ_BATTERY_ADC_GPIO, + &unit, + &channel); + if (err != ESP_OK || unit != ADC_UNIT_1) { + ESP_LOGE(TAG, + "Battery ADC GPIO%d is not a supported ADC1 input: %s", + CONFIG_TQ_BATTERY_ADC_GPIO, + esp_err_to_name(err)); + unlock_adc(); + return err != ESP_OK ? err : ESP_ERR_NOT_SUPPORTED; + } + + adc_oneshot_unit_init_cfg_t unit_config = { + .unit_id = unit, + .ulp_mode = ADC_ULP_MODE_DISABLE, + }; + err = adc_oneshot_new_unit(&unit_config, &s_adc_handle); + if (err != ESP_OK) { + unlock_adc(); + return err; + } + + adc_oneshot_chan_cfg_t channel_config = { + .atten = ADC_ATTEN_DB_12, + .bitwidth = ADC_BITWIDTH_DEFAULT, + }; + err = adc_oneshot_config_channel(s_adc_handle, channel, &channel_config); + if (err != ESP_OK) { + (void)adc_oneshot_del_unit(s_adc_handle); + s_adc_handle = NULL; + unlock_adc(); + return err; + } + + err = create_calibration(unit, channel); + if (err != ESP_OK) { + (void)adc_oneshot_del_unit(s_adc_handle); + s_adc_handle = NULL; + unlock_adc(); + return err; + } + + s_adc_channel = channel; + s_initialized = true; + ESP_LOGI(TAG, + "Battery monitor ready ADC_GPIO=%d divider=%d/%d charge_GPIO=%d", + CONFIG_TQ_BATTERY_ADC_GPIO, + CONFIG_TQ_BATTERY_DIVIDER_HIGH_OHM, + CONFIG_TQ_BATTERY_DIVIDER_LOW_OHM, + CONFIG_TQ_BATTERY_CHARGE_GPIO); + unlock_adc(); + return ESP_OK; +} + +static void snapshot_load_state(bool *out_active, bool *out_cooldown) +{ + bool active = false; + int64_t cooldown_until_us = 0; + + portENTER_CRITICAL(&s_state_lock); + for (size_t i = 0; i < PLATFORM_BATTERY_LOAD_COUNT; ++i) { + if (s_load_ref_count[i] > 0U) { + active = true; + break; + } + } + cooldown_until_us = s_cooldown_until_us; + portEXIT_CRITICAL(&s_state_lock); + + *out_active = active; + *out_cooldown = !active && esp_timer_get_time() < cooldown_until_us; +} + +esp_err_t platform_battery_read_sample(platform_battery_sample_t *out_sample) +{ + if (out_sample == NULL) { + return ESP_ERR_INVALID_ARG; + } + memset(out_sample, 0, sizeof(*out_sample)); + + esp_err_t err = platform_battery_init(); + if (err != ESP_OK) { + return err; + } + if (!lock_adc()) { + return ESP_ERR_TIMEOUT; + } + + int raw = 0; + for (size_t i = 0; i < 4U; ++i) { + err = adc_oneshot_read(s_adc_handle, s_adc_channel, &raw); + if (err != ESP_OK) { + unlock_adc(); + return err; + } + esp_rom_delay_us(BATTERY_ADC_SAMPLE_DELAY_US); + } + + int samples_mv[BATTERY_ADC_SAMPLE_COUNT]; + for (size_t i = 0; i < BATTERY_ADC_SAMPLE_COUNT; ++i) { + err = adc_oneshot_read(s_adc_handle, s_adc_channel, &raw); + if (err != ESP_OK) { + unlock_adc(); + return err; + } + err = adc_cali_raw_to_voltage(s_cali_handle, raw, &samples_mv[i]); + if (err != ESP_OK) { + unlock_adc(); + return err; + } + esp_rom_delay_us(BATTERY_ADC_SAMPLE_DELAY_US); + } + + sort_ints(samples_mv, BATTERY_ADC_SAMPLE_COUNT); + int64_t sum_mv = 0; + for (size_t i = BATTERY_ADC_TRIM_COUNT; + i < BATTERY_ADC_SAMPLE_COUNT - BATTERY_ADC_TRIM_COUNT; + ++i) { + sum_mv += samples_mv[i]; + } + int adc_mv = (int)(sum_mv / + (BATTERY_ADC_SAMPLE_COUNT - 2 * BATTERY_ADC_TRIM_COUNT)); + int64_t battery_mv = (int64_t)adc_mv * + ((int64_t)CONFIG_TQ_BATTERY_DIVIDER_HIGH_OHM + + CONFIG_TQ_BATTERY_DIVIDER_LOW_OHM) / + CONFIG_TQ_BATTERY_DIVIDER_LOW_OHM; + battery_mv = battery_mv * CONFIG_TQ_BATTERY_CALIBRATION_GAIN_PPM / 1000000LL + + CONFIG_TQ_BATTERY_CALIBRATION_OFFSET_MV; + + out_sample->adc_voltage_mv = adc_mv; + out_sample->voltage_mv = battery_mv > INT_MAX ? INT_MAX : (int)battery_mv; + out_sample->voltage_valid = out_sample->voltage_mv >= BATTERY_VALID_MIN_MV && + out_sample->voltage_mv <= BATTERY_VALID_MAX_MV; + snapshot_load_state(&out_sample->high_load_active, &out_sample->cooldown_active); + + if (CONFIG_TQ_BATTERY_CHARGE_GPIO < 0) { + out_sample->charge_state = PLATFORM_BATTERY_CHARGE_UNKNOWN; + } else { + int level = gpio_get_level((gpio_num_t)CONFIG_TQ_BATTERY_CHARGE_GPIO); + out_sample->charge_state = level == CONFIG_TQ_BATTERY_CHARGE_ACTIVE_LEVEL + ? PLATFORM_BATTERY_CHARGE_CHARGING + : PLATFORM_BATTERY_CHARGE_NOT_CHARGING; + } + unlock_adc(); + return ESP_OK; +} + +void platform_battery_load_begin(platform_battery_load_source_t source) +{ + if (source < 0 || source >= PLATFORM_BATTERY_LOAD_COUNT) { + return; + } + portENTER_CRITICAL(&s_state_lock); + if (s_load_ref_count[source] < UINT16_MAX) { + ++s_load_ref_count[source]; + } + portEXIT_CRITICAL(&s_state_lock); +} + +void platform_battery_load_end(platform_battery_load_source_t source) +{ + if (source < 0 || source >= PLATFORM_BATTERY_LOAD_COUNT) { + return; + } + int64_t cooldown_until_us = esp_timer_get_time() + + (int64_t)CONFIG_TQ_BATTERY_LOAD_COOLDOWN_SEC * 1000000LL; + + portENTER_CRITICAL(&s_state_lock); + if (s_load_ref_count[source] > 0U) { + --s_load_ref_count[source]; + if (cooldown_until_us > s_cooldown_until_us) { + s_cooldown_until_us = cooldown_until_us; + } + } + portEXIT_CRITICAL(&s_state_lock); +} + +#else + +esp_err_t platform_battery_init(void) +{ + return ESP_ERR_NOT_SUPPORTED; +} + +esp_err_t platform_battery_read_sample(platform_battery_sample_t *out_sample) +{ + if (out_sample != NULL) { + memset(out_sample, 0, sizeof(*out_sample)); + } + return ESP_ERR_NOT_SUPPORTED; +} + +void platform_battery_load_begin(platform_battery_load_source_t source) +{ + (void)source; +} + +void platform_battery_load_end(platform_battery_load_source_t source) +{ + (void)source; +} + +#endif diff --git a/components/platform/src/runtime/platform_core_runtime.c b/components/platform/src/runtime/platform_core_runtime.c index 0c877f1..26783c2 100644 --- a/components/platform/src/runtime/platform_core_runtime.c +++ b/components/platform/src/runtime/platform_core_runtime.c @@ -13,6 +13,7 @@ #include "nvs.h" #include "nvs_flash.h" #include "platform_internal.h" +#include "platform_battery_internal.h" #include "sdkconfig.h" #ifndef CONFIG_TQ_STEPPER_ENABLE @@ -256,9 +257,16 @@ esp_err_t platform_stepper_rotate_angle_with_accel_timeout(float angle, } } - return platform_stepper_driver_rotate_angle_with_accel(angle, - target_delay_us, - timeout_ms); + if (angle == 0.0f) { + return ESP_OK; + } + + platform_battery_load_begin(PLATFORM_BATTERY_LOAD_STEPPER); + esp_err_t err = platform_stepper_driver_rotate_angle_with_accel(angle, + target_delay_us, + timeout_ms); + platform_battery_load_end(PLATFORM_BATTERY_LOAD_STEPPER); + return err; } void platform_stepper_power_off(void) diff --git a/components/platform/src/runtime/platform_lcd_runtime.c b/components/platform/src/runtime/platform_lcd_runtime.c index ef0ee2e..4c64cfb 100644 --- a/components/platform/src/runtime/platform_lcd_runtime.c +++ b/components/platform/src/runtime/platform_lcd_runtime.c @@ -22,6 +22,11 @@ esp_err_t platform_stop_lcd_render_loop(int timeout_ms) return lcd_direct_stop_render_loop(timeout_ms); } +esp_err_t platform_lcd_set_status_overlay(const platform_lcd_status_overlay_t *overlay) +{ + return lcd_direct_set_status_overlay(overlay); +} + esp_err_t platform_lcd_request_presentation(const platform_lcd_presentation_request_t *request) { return lcd_direct_request_presentation(request); diff --git a/components/platform/src/runtime/platform_multimodal_runtime.c b/components/platform/src/runtime/platform_multimodal_runtime.c index e0d0262..7445fe4 100644 --- a/components/platform/src/runtime/platform_multimodal_runtime.c +++ b/components/platform/src/runtime/platform_multimodal_runtime.c @@ -10,6 +10,7 @@ #include "multimodal/audio_pipeline.h" #include "multimodal/transport_ws.h" +#include "platform_battery_internal.h" #include "freertos/FreeRTOS.h" #include "freertos/portmacro.h" @@ -17,6 +18,38 @@ static platform_multimodal_transport_callbacks_t s_multimodal_transport_callback static portMUX_TYPE s_multimodal_transport_cb_lock = portMUX_INITIALIZER_UNLOCKED; static platform_multimodal_downstream_playback_callbacks_t s_multimodal_downstream_playback_callbacks; static portMUX_TYPE s_multimodal_downstream_playback_cb_lock = portMUX_INITIALIZER_UNLOCKED; +static uint32_t s_battery_playback_generation; +static bool s_battery_playback_active; + +static void platform_battery_note_playback_event( + multimodal_audio_downstream_playback_event_type_t type, + uint32_t generation) +{ + bool begin_load = false; + bool end_load = false; + + portENTER_CRITICAL(&s_multimodal_downstream_playback_cb_lock); + if (type == MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_STARTED) { + if (!s_battery_playback_active) { + s_battery_playback_active = true; + begin_load = true; + } + s_battery_playback_generation = generation; + } else if (s_battery_playback_active && + s_battery_playback_generation == generation) { + s_battery_playback_active = false; + s_battery_playback_generation = 0U; + end_load = true; + } + portEXIT_CRITICAL(&s_multimodal_downstream_playback_cb_lock); + + if (begin_load) { + platform_battery_load_begin(PLATFORM_BATTERY_LOAD_AUDIO); + } + if (end_load) { + platform_battery_load_end(PLATFORM_BATTERY_LOAD_AUDIO); + } +} static void snapshot_transport_callbacks( platform_multimodal_transport_callbacks_t *out_callbacks) @@ -228,6 +261,8 @@ static void platform_multimodal_downstream_playback_bridge( return; } + platform_battery_note_playback_event(event->type, event->playback_generation); + switch (event->type) { case MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_STARTED: if (callbacks.on_started != NULL) { diff --git a/docs/adr/0005-battery-management-ownership.md b/docs/adr/0005-battery-management-ownership.md new file mode 100644 index 0000000..2a43e3f --- /dev/null +++ b/docs/adr/0005-battery-management-ownership.md @@ -0,0 +1,43 @@ +# ADR 0005: Battery Management Ownership + +## Status + +Accepted + +## Context + +The current board uses GPIO9 to measure a protected 600 mAh single-cell Li-ion +battery through a 100 kOhm / 100 kOhm divider. GPIO10 receives the active-low +TP4057 charge indication. Battery state is affected by ADC calibration, audio +and stepper load transients, SOC estimation, threshold hysteresis, and runtime +policy, so it cannot have duplicate state in several layers. + +## Decision + +- `platform` owns ADC1 setup, calibration, trimmed-mean sampling, divider + conversion, TP4057 GPIO sampling, and internal audio/stepper load-quality + tracking. +- `domain` owns the single battery status snapshot, voltage filtering, SOC + curve interpolation, implausible-jump confirmation, charge/discharge + direction constraints, and low/critical hysteresis. +- `control_plane` owns the periodic monitoring worker and reactions to semantic + state transitions. +- `domain` maps its battery snapshot to semantic LCD visibility, while + `platform` owns transparent PNG composition and panel serialization. +- The first release logs low and critical transitions but intentionally does + not stop actuators, enter sleep, or cut power. Typed shutdown preparation + remains a reserved, unsupported hook until hardware wake and shutdown policy + are decided. +- `app_composition` keeps using the aggregate control-plane runtime lifecycle; + it does not acquire battery samples directly. + +## Consequences + +- GPIO9 must never be configured as the legacy power-hold output on this board. +- High-load samples remain available for diagnostics but cannot update SOC or + low-battery state. Normal processing resumes after the configured cooldown. +- GPIO10 cannot distinguish a full battery from an unplugged charger because + both produce a high level; the public state therefore reports only charging + versus not charging. +- The voltage-to-SOC curve is an initial approximation and requires bench + calibration against measured battery voltage and a controlled discharge run. diff --git a/docs/battery-management.md b/docs/battery-management.md new file mode 100644 index 0000000..db6ce5c --- /dev/null +++ b/docs/battery-management.md @@ -0,0 +1,67 @@ +# Battery Management + +## Hardware Profile + +- Cell: protected single-cell Li-ion, 600 mAh, 3.7 V nominal, 4.2 V full. +- Supported measurement range: 3.3 V to 4.2 V for SOC estimation. +- GPIO9: ADC1 battery sense through R17=100 kOhm (VBAT to ADC) and R18=100 + kOhm (ADC to ground). `VBAT = VADC * 2`. +- GPIO10: TP4057 active-low charge output with an input pull-up. A high level + means either charge complete or charger absent. +- Legacy power hold: disabled (`CONFIG_TQ_POWER_HOLD_GPIO=-1`). + +## Processing Pipeline + +Each hardware sample consists of 16 calibrated ADC readings. The platform +driver sorts them, discards the lowest four and highest four, averages the +remaining eight, and applies the divider ratio. Domain processing then applies: + +1. A 2.5 V to 4.5 V plausibility check. +2. Three-sample confirmation for voltage jumps greater than 300 mV. +3. An exponential filter with 80% previous and 20% new voltage. +4. Piecewise-linear interpolation over the Li-ion discharge curve. +5. A two-percent maximum SOC change per accepted sample. +6. Charge/discharge direction constraints. +7. Three-sample confirmation and hysteresis for low and critical states. + +The initial state thresholds are 20%/25% for low entry/recovery and 5%/10% for +critical entry/recovery. + +Board calibration can be adjusted without source changes using +`CONFIG_TQ_BATTERY_CALIBRATION_GAIN_PPM` and +`CONFIG_TQ_BATTERY_CALIBRATION_OFFSET_MV` after comparing the log against a +multimeter. + +## High-Load Handling + +Audio playback events and synchronous stepper rotations update one +platform-owned load tracker. While a load is active and for five seconds after +its terminal event, samples are marked suppressed. The monitor continues to +poll once per second for diagnostics and charge state, but suppressed voltage +does not update filtered voltage, SOC, or low-battery state. + +Playback generations prevent a stale terminal event from ending suppression +for a newer playback. Stepper cleanup executes after every synchronous return, +including errors and timeouts. + +## LCD Status Overlays + +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. + +## Deferred Policy + +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. + +## Bench Calibration + +After flashing, compare the `cp_battery` voltage log with a multimeter at no +load near 4.2 V, 3.9 V, 3.6 V, and 3.3 V. Record audio and stepper voltage sag, +then run a controlled discharge to tune the SOC curve and threshold timing. diff --git a/docs/boot-sequence-plan-a.md b/docs/boot-sequence-plan-a.md index 0907899..9e23d1a 100644 --- a/docs/boot-sequence-plan-a.md +++ b/docs/boot-sequence-plan-a.md @@ -23,7 +23,7 @@ dark. This is accepted for this plan. ## Startup Order ```text -power hold +power-hold configuration (disabled on the current battery-sensing board) -> audio init -> play boot sound with timeout -> audio deinit or idle release @@ -42,7 +42,7 @@ power hold | Order | Stage | Wait policy | Failure policy | Notes | | --- | --- | --- | --- | --- | -| 1 | Power hold GPIO | Wait | Continue only if board power remains stable | Configure the hold GPIO and assert the active level before any heavier work. | +| 1 | Power-hold configuration | Wait | Continue only if board power remains stable | The current board sets the hold GPIO to `-1`; legacy boards may configure and assert a dedicated hold output. GPIO9 is reserved for battery ADC. | | 2 | Audio init | Wait with bounded timeout | Log and skip boot sound | Initializes the codec path needed for playback: I2C, I2S, codec, PA control, codec buffers, and decoder resources. | | 3 | Boot sound playback | Wait for completion or timeout | Stop playback and continue boot | The sound must not block startup indefinitely. Use a short product-defined timeout. | | 4 | Audio release | Wait for synchronous release | Continue if release reports a recoverable error | If Wi-Fi/TLS starts soon after, deinit or release idle audio resources to reduce DMA/internal heap pressure. | @@ -53,7 +53,7 @@ power hold | 9 | Turntable calibration | Product decision; default wait with timeout | Continue with degraded direction accuracy on timeout/failure | This is the mechanical SW1 homing/calibration process, not the stepper electrical init. | | 10 | Storage prepare | Wait | Stop features that need persistent state | Initializes NVS before Wi-Fi credentials, identity, auth, and fortune state are read or written. | | 11 | Wi-Fi bootstrap | Wait until STA connected or AP config mode starts | Enter config AP or keep retry policy active | Must not overlap with boot sound playback or heavy audio initialization. | -| 12 | Runtime workers | Start and continue | Stop only failed dependent feature | Starts PTT, slot switch, Wi-Fi state monitor, model command worker, LCD runtime worker, and WebSocket wait worker as configured. | +| 12 | Runtime workers | Start and continue | Stop only failed dependent feature | Starts battery monitoring, PTT, slot switch, Wi-Fi state monitor, model command worker, LCD runtime worker, and WebSocket wait worker as configured. | | 13 | Network services | Async after network readiness | Retry using existing policy | Includes multimodal WebSocket, LAN HTTP config server, SNTP, HTTPS fortune refresh, and auth flows. | ## Clarifications diff --git a/docs/firmware-pin-usage.md b/docs/firmware-pin-usage.md index 7571c0d..17a6b0d 100644 --- a/docs/firmware-pin-usage.md +++ b/docs/firmware-pin-usage.md @@ -15,7 +15,8 @@ the ESP32-S3 MCU perspective. | 6 | `CONFIG_TQ_SLOT_SWITCH_GPIO` | Input | Slot / key input | Rocker microswitch / original SW2 function | Uses the unconnected `TP_SCL` contact; active-low with internal pull-up | | 7 | `CONFIG_TQ_SCREEN_BACKLIGHT_GPIO` | Output | LCD | LCD adapter backlight control | Active-high by default | | 8 | `CONFIG_TQ_SCREEN_SPI_CLK_GPIO` | Output | LCD | ST7789P3 SPI clock / SCK | SPI2 host, 20 MHz default | -| 9 | `CONFIG_TQ_POWER_HOLD_GPIO` | Output | Power | Board power hold | Active-high by default | +| 9 | `CONFIG_TQ_BATTERY_ADC_GPIO` | Analog input | Battery | Battery voltage through 100 kOhm / 100 kOhm divider | ADC1 input; do not configure as a power-hold output | +| 10 | `CONFIG_TQ_BATTERY_CHARGE_GPIO` | Input | Battery charger | TP4057 charge status | Active-low, internal pull-up enabled | | 11 | `CONFIG_MULTIMODAL_AUDIO_PA_GPIO` | Output | Audio amplifier | PA enable | Managed by codec GPIO interface | | 12 | `CONFIG_TQ_CALIBRATION_SWITCH_GPIO` | Input | Turntable calibration | Button 1 / SW1 direction calibration | Active-low, external and internal pull-up | | 13 | `CONFIG_MULTIMODAL_PTT_BUTTON_GPIO` | Input | Keys / multimodal | Button 2 / K2 push-to-talk | Active-low, external and internal pull-up | @@ -46,6 +47,7 @@ is installed. | `CONFIG_TQ_SLOT_SWITCH_PULLDOWN` | unset | SW2 uses pull-up only. | | `CONFIG_TQ_CALIBRATION_SWITCH_PULLDOWN` | unset | Calibration switch uses pull-up only. | | `CONFIG_MULTIMODAL_PTT_BUTTON_PULLDOWN` | unset | Button 2 uses pull-up only. | +| `CONFIG_TQ_POWER_HOLD_GPIO` | `-1` | Legacy board power-hold output is disabled because GPIO9 is the battery ADC input. | ## Boot Hardware Test Profile @@ -90,7 +92,6 @@ them to an active project feature. | --- | --- | --- | | GPIO0 / K1 | Switch to GND, commonly boot/download related | Not consumed by application code | | GPIO3 / `touch` | TTP223 touch output | Present on the board but not consumed by the current input policy | -| GPIO10 / `Charge` | Charger status | No current firmware consumer found | | GPIO14 | Unassigned | Not used | | GPIO35 | Unassigned | Not used | | GPIO36 | Unassigned | Not used | @@ -107,6 +108,7 @@ them to an active project feature. - `docs/gpio-map.md` - `components/platform/src/lcd_direct.c` - `components/platform/src/platform_stepper_driver.c` +- `components/platform/src/platform_battery_driver.c` - `components/platform/src/multimodal/audio_pipeline/audio_pipeline_codec_impl.c` - `components/control_plane/src/runtime/control_plane_key_input.c` - `components/platform/src/platform_console_driver.c` diff --git a/docs/gpio-map.md b/docs/gpio-map.md index 7d182ae..4e5d08d 100644 --- a/docs/gpio-map.md +++ b/docs/gpio-map.md @@ -1,8 +1,9 @@ # ESP32-S3 GPIO Map -This document defines the reference GPIO assignments for the ESP32-S3 board -from `SCH_招财猫0.3&商界0.2_2026-07-24.pdf`. Signal direction is described from -the MCU's perspective. +This document defines the active firmware GPIO assignments for the current +battery-sensing ESP32-S3 board revision. Signal direction is described from the +MCU's perspective. Older 2026-07-24 drawings assigned GPIO9 to a legacy power +hold net; that assignment is not valid for the current board. ## GPIO Table @@ -24,8 +25,8 @@ the MCU's perspective. | 14 | GPIO20 | ESP_D_P | Bidirectional | USB D+ | Type-C | | 15 | GPIO3 | touch | Input | TTP223 touch-button output | Present on the board but not consumed by the current input policy | | 16 | GPIO46 | UNASSIGNED | N/A | No connection assigned | Reserved; no signal assigned | -| 17 | GPIO9 | Power | Output | Board power hold | Active high | -| 18 | GPIO10 | Charge | Input | Charger status | Not consumed by current firmware | +| 17 | GPIO9 | Power_t | Analog input | Battery voltage sense | R17=100 kOhm from VBAT and R18=100 kOhm to GND; ADC voltage is one half of VBAT | +| 18 | GPIO10 | Charge | Input | TP4057 charger status | Active-low while charging; high means full or external power absent | | 19 | GPIO11 | PA_CTRL | Output | Audio power-amplifier enable | Amplifier control | | 20 | GPIO12 | SW1 | Input | Button 1 / turntable calibration switch | External 10 kOhm pull-up; active low | | 21 | GPIO13 | SW2 | Input | Button 2 / K2 push-to-talk input | External 10 kOhm pull-up; active low | diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index 26cce5d..97d9be4 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -718,12 +718,12 @@ menu "Example Configuration" config TQ_POWER_HOLD_GPIO int "Power hold GPIO (-1 to disable)" - default 9 + default -1 range -1 48 help - GPIO used to keep board power enabled after boot. The - 2026-07-24 board maps the Power net to IO9. Set to -1 only - when no external power-hold circuit is connected. + GPIO used to keep board power enabled after boot. The current + battery-sensing board uses IO9 as an ADC input and therefore + disables the legacy power-hold output by default. config TQ_POWER_HOLD_ACTIVE_LEVEL int "Power hold active level" @@ -732,6 +732,88 @@ menu "Example Configuration" help Logic level that keeps the external power-hold circuit active. + config TQ_BATTERY_ENABLE + bool "Enable battery monitoring" + default y + + config TQ_BATTERY_ADC_GPIO + int "Battery voltage ADC GPIO" + default 9 + range 0 48 + depends on TQ_BATTERY_ENABLE + + config TQ_BATTERY_CHARGE_GPIO + int "TP4057 charge status GPIO (-1 to disable)" + default 10 + range -1 48 + depends on TQ_BATTERY_ENABLE + + config TQ_BATTERY_CHARGE_ACTIVE_LEVEL + int "TP4057 charging active level" + default 0 + range 0 1 + depends on TQ_BATTERY_ENABLE + + config TQ_BATTERY_DIVIDER_HIGH_OHM + int "Battery divider resistance from VBAT to ADC (ohm)" + default 100000 + range 1 10000000 + depends on TQ_BATTERY_ENABLE + + config TQ_BATTERY_DIVIDER_LOW_OHM + int "Battery divider resistance from ADC to ground (ohm)" + default 100000 + range 1 10000000 + depends on TQ_BATTERY_ENABLE + + config TQ_BATTERY_CALIBRATION_GAIN_PPM + int "Board battery voltage calibration gain (ppm)" + default 1000000 + range 800000 1200000 + depends on TQ_BATTERY_ENABLE + + config TQ_BATTERY_CALIBRATION_OFFSET_MV + int "Board battery voltage calibration offset (mV)" + default 0 + range -500 500 + depends on TQ_BATTERY_ENABLE + + config TQ_BATTERY_SAMPLE_INTERVAL_SEC + int "Battery monitoring interval (seconds)" + default 10 + range 1 3600 + depends on TQ_BATTERY_ENABLE + + config TQ_BATTERY_LOAD_COOLDOWN_SEC + int "Battery sampling cooldown after high load (seconds)" + default 5 + range 0 60 + depends on TQ_BATTERY_ENABLE + + config TQ_BATTERY_LOW_ENTER_PERCENT + int "Low battery entry threshold (percent)" + default 20 + range 1 99 + depends on TQ_BATTERY_ENABLE + + config TQ_BATTERY_LOW_EXIT_PERCENT + int "Low battery recovery threshold (percent)" + default 25 + range 1 100 + depends on TQ_BATTERY_ENABLE + + config TQ_BATTERY_CRITICAL_ENTER_PERCENT + int "Critical battery entry threshold (percent)" + default 5 + range 0 99 + depends on TQ_BATTERY_ENABLE + + config TQ_BATTERY_CRITICAL_EXIT_PERCENT + int "Critical battery recovery threshold (percent)" + default 10 + range 1 100 + depends on TQ_BATTERY_ENABLE + endmenu menu "Wi-Fi Provisioning" diff --git a/sdkconfig.defaults b/sdkconfig.defaults index a861fc8..4d56079 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -301,6 +301,20 @@ CONFIG_TQ_CALIBRATION_SWITCH_PULLUP=y # CONFIG_TQ_CALIBRATION_SWITCH_PULLDOWN is not set # CONFIG_TQ_SCREEN_COLOR_AUTOTEST is not set CONFIG_TQ_SCREEN_COLOR_AUTOTEST_INTERVAL_MS=3000 -CONFIG_TQ_POWER_HOLD_GPIO=9 +CONFIG_TQ_POWER_HOLD_GPIO=-1 CONFIG_TQ_POWER_HOLD_ACTIVE_LEVEL=1 +CONFIG_TQ_BATTERY_ENABLE=y +CONFIG_TQ_BATTERY_ADC_GPIO=9 +CONFIG_TQ_BATTERY_CHARGE_GPIO=10 +CONFIG_TQ_BATTERY_CHARGE_ACTIVE_LEVEL=0 +CONFIG_TQ_BATTERY_DIVIDER_HIGH_OHM=100000 +CONFIG_TQ_BATTERY_DIVIDER_LOW_OHM=100000 +CONFIG_TQ_BATTERY_CALIBRATION_GAIN_PPM=1000000 +CONFIG_TQ_BATTERY_CALIBRATION_OFFSET_MV=0 +CONFIG_TQ_BATTERY_SAMPLE_INTERVAL_SEC=10 +CONFIG_TQ_BATTERY_LOAD_COOLDOWN_SEC=5 +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_MULTIMODAL_AUDIO_PA_GPIO=11