From 1c8808339aab2e77d5f4d6225cf93aee54015bcc Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Mon, 12 Oct 2026 17:23:55 -0400 Subject: [PATCH] [power_management] Add the power_management domain with an esp32_power_management platform (#12325) Co-authored-by: Kevin Ahrendt Co-authored-by: Kevin Ahrendt Co-authored-by: Claude Sonnet 4.6 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 + esphome/components/esp32/gpio.cpp | 4 + .../esp32_power_management/__init__.py | 1 + .../esp32_power_management.cpp | 75 ++++ .../esp32_power_management.h | 28 ++ .../power_management.py | 254 ++++++++++++ .../components/power_management/__init__.py | 16 + .../power_management/power_management.h | 9 + .../esp32_power_management/__init__.py | 1 + .../esp32_power_management/test_init.py | 361 ++++++++++++++++++ .../esp32_power_management/common.yaml | 11 + .../test.esp32-ard.yaml | 7 + .../test.esp32-h2-idf.yaml | 7 + .../test.esp32-idf.yaml | 7 + 14 files changed, 783 insertions(+) create mode 100644 esphome/components/esp32_power_management/__init__.py create mode 100644 esphome/components/esp32_power_management/esp32_power_management.cpp create mode 100644 esphome/components/esp32_power_management/esp32_power_management.h create mode 100644 esphome/components/esp32_power_management/power_management.py create mode 100644 esphome/components/power_management/__init__.py create mode 100644 esphome/components/power_management/power_management.h create mode 100644 tests/component_tests/esp32_power_management/__init__.py create mode 100644 tests/component_tests/esp32_power_management/test_init.py create mode 100644 tests/components/esp32_power_management/common.yaml create mode 100644 tests/components/esp32_power_management/test.esp32-ard.yaml create mode 100644 tests/components/esp32_power_management/test.esp32-h2-idf.yaml create mode 100644 tests/components/esp32_power_management/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 02e66742e5f..b45816640ed 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -184,6 +184,7 @@ esphome/components/esp32_camera_web_server/* @ayufan esphome/components/esp32_can/* @Sympatron esphome/components/esp32_hosted/* @swoboda1337 esphome/components/esp32_hosted/update/* @swoboda1337 +esphome/components/esp32_power_management/* @rwrozelle esphome/components/esp32_rmt/* @jesserockz esphome/components/esp32_rmt_led_strip/* @jesserockz esphome/components/esp8266/* @esphome/core @@ -436,6 +437,7 @@ esphome/components/pn7160/* @jesserockz @kbx81 esphome/components/pn7160_i2c/* @jesserockz @kbx81 esphome/components/pn7160_spi/* @jesserockz @kbx81 esphome/components/pn71xx/* @jesserockz @kbx81 +esphome/components/power_management/* @rwrozelle esphome/components/power_supply/* @esphome/core esphome/components/preferences/* @esphome/core esphome/components/provisioning/* @esphome/core diff --git a/esphome/components/esp32/gpio.cpp b/esphome/components/esp32/gpio.cpp index 59788e76dc7..d9e303c973b 100644 --- a/esphome/components/esp32/gpio.cpp +++ b/esphome/components/esp32/gpio.cpp @@ -164,6 +164,10 @@ void ESP32InternalGPIOPin::setup() { conf.pull_down_en = this->flags_ & gpio::FLAG_PULLDOWN ? GPIO_PULLDOWN_ENABLE : GPIO_PULLDOWN_DISABLE; conf.intr_type = GPIO_INTR_DISABLE; gpio_config(&conf); +#ifdef CONFIG_FREERTOS_USE_TICKLESS_IDLE + // If auto light sleep is used gpio sleep mode needs to be disabled for the pin + gpio_sleep_sel_dis(this->get_pin_num()); +#endif if (this->flags_ & gpio::FLAG_OUTPUT) { gpio_set_drive_capability(this->get_pin_num(), this->get_drive_strength()); } diff --git a/esphome/components/esp32_power_management/__init__.py b/esphome/components/esp32_power_management/__init__.py new file mode 100644 index 00000000000..76622032cdf --- /dev/null +++ b/esphome/components/esp32_power_management/__init__.py @@ -0,0 +1 @@ +DOMAIN = "esp32_power_management" diff --git a/esphome/components/esp32_power_management/esp32_power_management.cpp b/esphome/components/esp32_power_management/esp32_power_management.cpp new file mode 100644 index 00000000000..41ba352e5a6 --- /dev/null +++ b/esphome/components/esp32_power_management/esp32_power_management.cpp @@ -0,0 +1,75 @@ +#include "esp32_power_management.h" +#ifdef USE_ESP32 +#include "esphome/core/log.h" + +#include "esp_private/esp_clk.h" +#include "soc/rtc.h" + +namespace esphome::esp32_power_management { + +ESPHOME_LOG_TAG(TAG, "esp32_power_management"); + +void ESP32PowerManagement::setup() { + if (this->max_freq_mhz_ == 0) + this->max_freq_mhz_ = CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ; + if (this->min_freq_mhz_ == 0) + this->min_freq_mhz_ = esp_clk_xtal_freq() / MHZ; + // A low boot frequency (ESP32-H2 at 16 MHz) can sit below the crystal frequency. + if (this->min_freq_mhz_ > this->max_freq_mhz_) + this->min_freq_mhz_ = this->max_freq_mhz_; + + esp_pm_config_t pm_config = { + .max_freq_mhz = this->max_freq_mhz_, + .min_freq_mhz = this->min_freq_mhz_, +#if CONFIG_FREERTOS_USE_TICKLESS_IDLE + .light_sleep_enable = true, +#endif + }; + esp_err_t rc = esp_pm_configure(&pm_config); + if (rc != ESP_OK) { + // The usual cause is a minimum frequency that does not divide this board's crystal + ESP_LOGE(TAG, "esp_pm_configure failed: %s (crystal %u MHz)", esp_err_to_name(rc), + static_cast(esp_clk_xtal_freq() / MHZ)); + this->mark_failed(); + } +} + +void ESP32PowerManagement::dump_config() { + ESP_LOGCONFIG(TAG, + "Power Management:\n" + " Max Frequency: %uMHZ\n" + " Min Frequency: %uMHZ", + this->max_freq_mhz_, this->min_freq_mhz_); +#if SOC_CLK_XTAL32K_SUPPORTED + ESP_LOGCONFIG(TAG, " 32k XTAL: %s", + rtc_clk_slow_src_get() == SOC_RTC_SLOW_CLK_SRC_XTAL32K ? LOG_STR_LITERAL("in use") + : LOG_STR_LITERAL("not in use")); +#endif +#if CONFIG_FREERTOS_USE_TICKLESS_IDLE + ESP_LOGCONFIG(TAG, " Light Sleep Enabled"); +#if CONFIG_ESP_SLEEP_POWER_DOWN_FLASH + ESP_LOGCONFIG(TAG, " PM Flash Power Down in Light Sleep Enabled"); +#endif +#if CONFIG_PM_POWER_DOWN_PERIPHERAL_IN_LIGHT_SLEEP + ESP_LOGCONFIG(TAG, " PM Peripheral Power Down in Light Sleep Enabled"); +#endif +#if CONFIG_IEEE802154_SLEEP_ENABLE + ESP_LOGCONFIG(TAG, " ieee802154 Sleep Enabled"); +#endif +#if CONFIG_PM_PROFILING + ESP_LOGCONFIG(TAG, " PM Profiling Enabled"); +#endif +#if CONFIG_PM_TRACE + ESP_LOGCONFIG(TAG, " PM Trace Enabled"); +#endif +#if defined(CONFIG_LWIP_IPV6) && !defined(CONFIG_LWIP_ND6) + ESP_LOGCONFIG(TAG, " IPv6 Neighbor Discovery Disabled (CONFIG_LWIP_ND6)"); +#endif +#if CONFIG_USJ_NO_AUTO_LS_ON_CONNECTION + ESP_LOGCONFIG(TAG, " USB Serial/JTAG Light Sleep Prevention Enabled"); +#endif +#endif +} + +} // namespace esphome::esp32_power_management +#endif diff --git a/esphome/components/esp32_power_management/esp32_power_management.h b/esphome/components/esp32_power_management/esp32_power_management.h new file mode 100644 index 00000000000..e6194e76813 --- /dev/null +++ b/esphome/components/esp32_power_management/esp32_power_management.h @@ -0,0 +1,28 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "esphome/core/component.h" +#include "esphome/components/power_management/power_management.h" +// Lets a YAML lambda call esp_pm_dump_locks(). +#include "esp_pm.h" + +namespace esphome::esp32_power_management { + +class ESP32PowerManagement : public power_management::PowerManagementComponent { + public: + float get_setup_priority() const override { return setup_priority::POWER; } + void setup() override; + void dump_config() override; + void set_max_freq_mhz(uint16_t max_freq_mhz) { this->max_freq_mhz_ = max_freq_mhz; } + void set_min_freq_mhz(uint16_t min_freq_mhz) { this->min_freq_mhz_ = min_freq_mhz; } + + protected: + // Zero means the build default; setup() replaces it with the value applied. + uint16_t max_freq_mhz_{0}; + uint16_t min_freq_mhz_{0}; +}; + +} // namespace esphome::esp32_power_management + +#endif diff --git a/esphome/components/esp32_power_management/power_management.py b/esphome/components/esp32_power_management/power_management.py new file mode 100644 index 00000000000..e9b6d6462cd --- /dev/null +++ b/esphome/components/esp32_power_management/power_management.py @@ -0,0 +1,254 @@ +import logging + +import esphome.codegen as cg +from esphome.components import esp32, power_management +from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + add_idf_sdkconfig_option, + get_esp32_variant, +) +from esphome.components.openthread.const import CONF_POLL_PERIOD +from esphome.components.psram import DOMAIN as PSRAM_DOMAIN +from esphome.components.zigbee import DOMAIN as ZIGBEE_DOMAIN +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_OPENTHREAD, CONF_PLATFORM +from esphome.core import CORE +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +CODEOWNERS = ["@rwrozelle"] +DEPENDENCIES = ["esp32"] + +CONF_ENABLE_LIGHT_SLEEP = "enable_light_sleep" +CONF_IDLE_TIME_BEFORE_SLEEP = "idle_time_before_sleep" +CONF_MAX_FREQUENCY = "max_frequency" +CONF_MIN_FREQUENCY = "min_frequency" +CONF_POWER_DOWN_FLASH = "power_down_flash" +CONF_POWER_DOWN_PERIPHERALS = "power_down_peripherals" +CONF_PROFILING = "profiling" +CONF_TRACE = "trace" + +# Variants with a TOP power domain, which can power down peripherals in light sleep. +_TOP_PD_VARIANTS = [ + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32H21, + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S31, +] + +# Crystal frequencies each variant can be built for (ESP-IDF's SOC_XTAL_FREQ_* values). +# Below the PLL frequencies the CPU runs from the crystal through an integer divider. +_XTAL_MHZ = { + VARIANT_ESP32: (24, 26, 40), + VARIANT_ESP32C2: (26, 32, 40), + VARIANT_ESP32C3: (32, 40), + VARIANT_ESP32C5: (40, 48), + VARIANT_ESP32C6: (40,), + VARIANT_ESP32C61: (40,), + VARIANT_ESP32H2: (32,), + VARIANT_ESP32H21: (32,), + VARIANT_ESP32H4: (32,), + VARIANT_ESP32P4: (40,), + VARIANT_ESP32S2: (40,), + VARIANT_ESP32S3: (32, 40), + VARIANT_ESP32S31: (40,), +} +# esp_pm_configure() keeps the 1 MHz reference clock reachable: its divider must be +# at least 10 on the ESP32 and 2 on every other variant. +_MIN_FREQUENCY_FLOOR_MHZ = {VARIANT_ESP32: 10} + +esp32_power_management_ns = cg.esphome_ns.namespace("esp32_power_management") +PowerManagement = esp32_power_management_ns.class_( + "ESP32PowerManagement", power_management.PowerManagementComponent +) + + +def _mhz(value: object) -> int: + """Validate a frequency and keep it as whole megahertz.""" + return int(cv.frequency(value)) // 1_000_000 + + +def valid_min_frequencies(variant: str) -> list[int]: + """The minimum CPU frequencies esp_pm_configure() accepts on a variant, in MHz. + + A value must divide one of the variant's crystal frequencies, stay above the + reference clock floor, or be one of the PLL frequencies. + """ + floor = _MIN_FREQUENCY_FLOOR_MHZ.get(variant, 2) + mhz = { + xtal // divider + for xtal in _XTAL_MHZ[variant] + for divider in range(1, xtal // floor + 1) + if xtal % divider == 0 + } + mhz.update(int(freq[:-3]) for freq in esp32.CPU_FREQUENCIES[variant]) + return sorted(mhz) + + +def _validate(config: ConfigType) -> ConfigType: + variant = get_esp32_variant() + if (max_mhz := config.get(CONF_MAX_FREQUENCY)) is not None: + valid = esp32.CPU_FREQUENCIES[variant] + if f"{max_mhz}MHZ" not in valid: + raise cv.Invalid( + f"{max_mhz}MHZ is not a valid CPU frequency for {variant}. " + f"Valid options are: {', '.join(valid)}", + path=[CONF_MAX_FREQUENCY], + ) + if (min_mhz := config.get(CONF_MIN_FREQUENCY)) is not None and ( + min_mhz not in (valid_min := valid_min_frequencies(variant)) + ): + raise cv.Invalid( + f"{min_mhz}MHZ is not a valid minimum frequency for {variant}; it must " + f"divide the crystal frequency. Valid options are: " + f"{', '.join(f'{mhz}MHZ' for mhz in valid_min)}", + path=[CONF_MIN_FREQUENCY], + ) + if not config[CONF_ENABLE_LIGHT_SLEEP]: + for key in (CONF_POWER_DOWN_PERIPHERALS, CONF_POWER_DOWN_FLASH): + if config.get(key): + raise cv.Invalid( + f"{key} requires {CONF_ENABLE_LIGHT_SLEEP}: true", path=[key] + ) + config[CONF_POWER_DOWN_PERIPHERALS] = False + elif CONF_POWER_DOWN_PERIPHERALS not in config: + config[CONF_POWER_DOWN_PERIPHERALS] = variant in _TOP_PD_VARIANTS + elif config[CONF_POWER_DOWN_PERIPHERALS] and variant not in _TOP_PD_VARIANTS: + raise cv.Invalid( + f"{CONF_POWER_DOWN_PERIPHERALS} is only available on " + f"{', '.join(_TOP_PD_VARIANTS)}", + path=[CONF_POWER_DOWN_PERIPHERALS], + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(PowerManagement), + cv.Optional(CONF_MAX_FREQUENCY): _mhz, + cv.Optional(CONF_MIN_FREQUENCY): _mhz, + cv.Optional(CONF_ENABLE_LIGHT_SLEEP, default=False): cv.boolean, + cv.Optional(CONF_IDLE_TIME_BEFORE_SLEEP, default=3): cv.int_range( + min=2, max=4294967295 + ), + cv.Optional(CONF_POWER_DOWN_PERIPHERALS): cv.boolean, + cv.Optional(CONF_POWER_DOWN_FLASH, default=False): cv.boolean, + cv.Optional(CONF_PROFILING, default=False): cv.boolean, + # Repurposes GPIOs as trace outputs, so keep it out of the visual editor. + cv.Optional( + CONF_TRACE, default=False, visibility=cv.Visibility.YAML_ONLY + ): cv.boolean, + } + ).extend(cv.COMPONENT_SCHEMA), + _validate, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await power_management.register_power_management(var, config) + add_idf_sdkconfig_option("CONFIG_PM_ENABLE", True) + + if (max_mhz := config.get(CONF_MAX_FREQUENCY)) is not None: + cg.add(var.set_max_freq_mhz(max_mhz)) + if (min_mhz := config.get(CONF_MIN_FREQUENCY)) is not None: + cg.add(var.set_min_freq_mhz(min_mhz)) + if config[CONF_PROFILING]: + add_idf_sdkconfig_option("CONFIG_PM_PROFILING", True) + if config[CONF_TRACE]: + add_idf_sdkconfig_option("CONFIG_PM_TRACE", True) + if not config[CONF_ENABLE_LIGHT_SLEEP]: + return + + # Automatic light sleep whenever no task is runnable. + add_idf_sdkconfig_option("CONFIG_FREERTOS_USE_TICKLESS_IDLE", True) + add_idf_sdkconfig_option("CONFIG_ESP_PHY_MAC_BB_PD", True) + # Kconfig's own "depends on" no-ops this where it doesn't apply. + add_idf_sdkconfig_option("CONFIG_USJ_NO_AUTO_LS_ON_CONNECTION", True) + add_idf_sdkconfig_option( + "CONFIG_FREERTOS_IDLE_TIME_BEFORE_SLEEP", config[CONF_IDLE_TIME_BEFORE_SLEEP] + ) + if config[CONF_POWER_DOWN_PERIPHERALS]: + add_idf_sdkconfig_option("CONFIG_PM_POWER_DOWN_PERIPHERAL_IN_LIGHT_SLEEP", True) + if config[CONF_POWER_DOWN_FLASH]: + add_idf_sdkconfig_option("CONFIG_ESP_SLEEP_POWER_DOWN_FLASH", True) + + ot_conf = CORE.config.get(CONF_OPENTHREAD) + # A polling (sleepy) Thread end device cannot answer neighbour solicitations. + if ( + ot_conf is not None + and (poll_period := ot_conf.get(CONF_POLL_PERIOD)) is not None + and poll_period.total_milliseconds > 0 + ): + add_idf_sdkconfig_option("CONFIG_LWIP_ND6", False) + if ot_conf is not None or ZIGBEE_DOMAIN in CORE.config: + add_idf_sdkconfig_option("CONFIG_IEEE802154_SLEEP_ENABLE", True) + + +def _final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + entries = full_config.get(power_management.DOMAIN, []) + if sum(entry.get(CONF_PLATFORM) == DOMAIN for entry in entries) > 1: + raise cv.Invalid(f"Only one {DOMAIN} instance is allowed") + boot_freq = full_config[esp32.DOMAIN][esp32.CONF_CPU_FREQUENCY] + boot_mhz = int(boot_freq[:-3]) + max_mhz = config.get(CONF_MAX_FREQUENCY) + # Without max_frequency the boot frequency is the ceiling esp_pm_configure() gets. + if (min_mhz := config.get(CONF_MIN_FREQUENCY)) is not None and min_mhz > ( + max_mhz if max_mhz is not None else boot_mhz + ): + raise cv.Invalid( + f"{CONF_MIN_FREQUENCY} must not be greater than the maximum frequency", + path=[CONF_MIN_FREQUENCY], + ) + if max_mhz is not None: + if boot_mhz > max_mhz: + _LOGGER.info( + "esp32.%s (%s) is higher than %s (%dMHZ); the CPU will be " + "downclocked to %dMHZ as soon as power management is set up", + esp32.CONF_CPU_FREQUENCY, + boot_freq, + CONF_MAX_FREQUENCY, + max_mhz, + max_mhz, + ) + elif boot_mhz < max_mhz: + _LOGGER.warning( + "esp32.%s (%s) is lower than %s (%dMHZ); the CPU may run as " + "high as %dMHZ under load once power management is set up", + esp32.CONF_CPU_FREQUENCY, + boot_freq, + CONF_MAX_FREQUENCY, + max_mhz, + max_mhz, + ) + if config[CONF_POWER_DOWN_FLASH] and PSRAM_DOMAIN in full_config: + raise cv.Invalid( + f"{CONF_POWER_DOWN_FLASH} is not allowed when the device has PSRAM", + path=[CONF_POWER_DOWN_FLASH], + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/power_management/__init__.py b/esphome/components/power_management/__init__.py new file mode 100644 index 00000000000..5afbb648be0 --- /dev/null +++ b/esphome/components/power_management/__init__.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +IS_PLATFORM_COMPONENT = True +CODEOWNERS = ["@rwrozelle"] +DOMAIN = "power_management" + +power_management_ns = cg.esphome_ns.namespace("power_management") +PowerManagementComponent = power_management_ns.class_( + "PowerManagementComponent", cg.Component +) + + +async def register_power_management(var: MockObj, config: ConfigType) -> None: + await cg.register_component(var, config) diff --git a/esphome/components/power_management/power_management.h b/esphome/components/power_management/power_management.h new file mode 100644 index 00000000000..a0c026162a7 --- /dev/null +++ b/esphome/components/power_management/power_management.h @@ -0,0 +1,9 @@ +#pragma once + +#include "esphome/core/component.h" + +namespace esphome::power_management { + +class PowerManagementComponent : public Component {}; + +} // namespace esphome::power_management diff --git a/tests/component_tests/esp32_power_management/__init__.py b/tests/component_tests/esp32_power_management/__init__.py new file mode 100644 index 00000000000..a543aec3001 --- /dev/null +++ b/tests/component_tests/esp32_power_management/__init__.py @@ -0,0 +1 @@ +"""Tests for the esp32_power_management component.""" diff --git a/tests/component_tests/esp32_power_management/test_init.py b/tests/component_tests/esp32_power_management/test_init.py new file mode 100644 index 00000000000..32665060b77 --- /dev/null +++ b/tests/component_tests/esp32_power_management/test_init.py @@ -0,0 +1,361 @@ +"""Tests for esp32_power_management configuration validation and sdkconfig wiring.""" + +from contextlib import nullcontext +import logging +from unittest import mock + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import ( + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32C6, + VARIANT_ESP32H2, +) +from esphome.components.esp32_power_management.power_management import ( + CONF_ENABLE_LIGHT_SLEEP, + CONF_IDLE_TIME_BEFORE_SLEEP, + CONF_MAX_FREQUENCY, + CONF_MIN_FREQUENCY, + CONF_POWER_DOWN_FLASH, + CONF_POWER_DOWN_PERIPHERALS, + CONFIG_SCHEMA, + _final_validate, + to_code, + valid_min_frequencies, +) +from esphome.components.openthread.const import CONF_DEVICE_TYPE, CONF_POLL_PERIOD +from esphome.const import CONF_OPENTHREAD, KEY_FRAMEWORK_VERSION, PlatformFramework +from esphome.core import CORE, TimePeriodMilliseconds +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +LIGHT_SLEEP = {CONF_ENABLE_LIGHT_SLEEP: True} + + +def _set_variant( + set_core_config: SetCoreConfigCallable, + variant: str, + /, + *, + full_config: ConfigType | None = None, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + core_data={KEY_FRAMEWORK_VERSION: cv.Version(5, 5, 5)}, + platform_data={KEY_VARIANT: variant}, + full_config=full_config, + ) + + +@pytest.mark.parametrize( + ("variant", "config", "expectation"), + [ + pytest.param( + VARIANT_ESP32, + {CONF_MAX_FREQUENCY: "96MHZ"}, + pytest.raises(cv.Invalid, match="not a valid CPU frequency"), + id="max_invalid_for_variant", + ), + pytest.param( + VARIANT_ESP32, + {CONF_POWER_DOWN_PERIPHERALS: True}, + pytest.raises(cv.Invalid, match="requires enable_light_sleep"), + id="power_down_peripherals_without_light_sleep", + ), + pytest.param( + VARIANT_ESP32, + {CONF_POWER_DOWN_FLASH: True}, + pytest.raises(cv.Invalid, match="requires enable_light_sleep"), + id="power_down_flash_without_light_sleep", + ), + pytest.param( + VARIANT_ESP32, + LIGHT_SLEEP | {CONF_POWER_DOWN_PERIPHERALS: True}, + pytest.raises(cv.Invalid, match="only available on"), + id="power_down_peripherals_unsupported_variant", + ), + pytest.param( + VARIANT_ESP32H2, + LIGHT_SLEEP | {CONF_POWER_DOWN_PERIPHERALS: True}, + nullcontext(), + id="power_down_peripherals_top_pd_variant", + ), + # The ESP32 keeps its reference clock divider at 10 or more + pytest.param( + VARIANT_ESP32, + {CONF_MIN_FREQUENCY: "8MHZ"}, + pytest.raises(cv.Invalid, match="not a valid minimum frequency"), + id="min_frequency_below_esp32_floor", + ), + # 26 MHz crystal modules exist, so 13 MHz is reachable on an ESP32 + pytest.param( + VARIANT_ESP32, + {CONF_MIN_FREQUENCY: "13MHZ"}, + nullcontext(), + id="min_frequency_esp32_26mhz_crystal", + ), + pytest.param( + VARIANT_ESP32, + {CONF_MIN_FREQUENCY: "80MHZ"}, + nullcontext(), + id="min_frequency_pll", + ), + # The ESP32-H2 runs from a 32 MHz crystal, so 8 MHz divides it and 10 MHz does not + pytest.param( + VARIANT_ESP32H2, + {CONF_MIN_FREQUENCY: "8MHZ"}, + nullcontext(), + id="min_frequency_h2_crystal_divider", + ), + pytest.param( + VARIANT_ESP32H2, + {CONF_MIN_FREQUENCY: "10MHZ"}, + pytest.raises(cv.Invalid, match="must divide the crystal"), + id="min_frequency_h2_not_a_divider", + ), + pytest.param( + VARIANT_ESP32C6, + {CONF_MIN_FREQUENCY: "1MHZ"}, + pytest.raises(cv.Invalid, match="not a valid minimum frequency"), + id="min_frequency_below_reference_clock_floor", + ), + ], +) +def test_schema_validation( + set_core_config: SetCoreConfigCallable, + variant: str, + config: ConfigType, + expectation, +) -> None: + _set_variant(set_core_config, variant) + with expectation: + CONFIG_SCHEMA(config) + + +@pytest.mark.parametrize( + ("variant", "expected"), + [ + pytest.param( + VARIANT_ESP32, [10, 12, 13, 20, 24, 26, 40, 80, 160, 240], id="esp32" + ), + pytest.param(VARIANT_ESP32H2, [2, 4, 8, 16, 32, 48, 64, 96], id="esp32h2"), + pytest.param( + VARIANT_ESP32C6, [2, 4, 5, 8, 10, 20, 40, 80, 120, 160], id="esp32c6" + ), + ], +) +def test_valid_min_frequencies(variant: str, expected: list[int]) -> None: + assert valid_min_frequencies(variant) == expected + + +def test_frequencies_are_kept_in_mhz(set_core_config: SetCoreConfigCallable) -> None: + _set_variant(set_core_config, VARIANT_ESP32) + config = CONFIG_SCHEMA({CONF_MAX_FREQUENCY: "160MHZ", CONF_MIN_FREQUENCY: 40e6}) + assert config[CONF_MAX_FREQUENCY] == 160 + assert config[CONF_MIN_FREQUENCY] == 40 + assert config[CONF_IDLE_TIME_BEFORE_SLEEP] == 3 + + +@pytest.mark.parametrize( + ("variant", "config", "expected"), + [ + pytest.param(VARIANT_ESP32H2, LIGHT_SLEEP, True, id="top_pd_light_sleep"), + pytest.param(VARIANT_ESP32, LIGHT_SLEEP, False, id="no_top_pd_light_sleep"), + pytest.param(VARIANT_ESP32H2, {}, False, id="no_light_sleep"), + pytest.param( + VARIANT_ESP32H2, + LIGHT_SLEEP | {CONF_POWER_DOWN_PERIPHERALS: False}, + False, + id="explicit_value_kept", + ), + ], +) +def test_power_down_peripherals_default( + set_core_config: SetCoreConfigCallable, + variant: str, + config: ConfigType, + expected: bool, +) -> None: + _set_variant(set_core_config, variant) + assert CONFIG_SCHEMA(config)[CONF_POWER_DOWN_PERIPHERALS] is expected + + +def _final_validate_config( + set_core_config: SetCoreConfigCallable, + config: ConfigType, + full_config: ConfigType | None = None, + boot_frequency: str = "160MHZ", +) -> None: + """Validate config, then run _final_validate against the given full config.""" + full_config = {"esp32": {"cpu_frequency": boot_frequency}} | (full_config or {}) + _set_variant(set_core_config, VARIANT_ESP32, full_config=full_config) + _final_validate(CONFIG_SCHEMA(config)) + + +def test_rejects_multiple_esp32_power_management_instances( + set_core_config: SetCoreConfigCallable, +) -> None: + with pytest.raises(cv.Invalid, match="Only one esp32_power_management instance"): + _final_validate_config( + set_core_config, + {}, + {"power_management": [{"platform": "esp32_power_management"}] * 2}, + ) + + +@pytest.mark.parametrize( + ("max_frequency", "boot_frequency", "expected"), + [ + pytest.param("160MHZ", "240MHZ", "downclocked to 160MHZ", id="boot_higher"), + pytest.param("240MHZ", "80MHZ", "may run as high as 240MHZ", id="boot_lower"), + pytest.param("160MHZ", "160MHZ", "", id="equal"), + ], +) +def test_boot_frequency_log( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, + max_frequency: str, + boot_frequency: str, + expected: str, +) -> None: + caplog.set_level(logging.INFO) + _final_validate_config( + set_core_config, + {CONF_MAX_FREQUENCY: max_frequency}, + boot_frequency=boot_frequency, + ) + assert expected in caplog.text + assert (caplog.text == "") is (expected == "") + + +@pytest.mark.parametrize( + ("config", "boot_frequency", "expectation"), + [ + pytest.param( + {CONF_MAX_FREQUENCY: "80MHZ", CONF_MIN_FREQUENCY: "160MHZ"}, + "240MHZ", + pytest.raises(cv.Invalid, match="must not be greater than"), + id="min_above_max", + ), + pytest.param( + {CONF_MIN_FREQUENCY: "160MHZ"}, + "80MHZ", + pytest.raises(cv.Invalid, match="must not be greater than"), + id="min_above_boot_frequency", + ), + pytest.param( + {CONF_MIN_FREQUENCY: "80MHZ"}, + "80MHZ", + nullcontext(), + id="min_equal_to_boot_frequency", + ), + ], +) +def test_min_frequency_against_effective_max( + set_core_config: SetCoreConfigCallable, + config: ConfigType, + boot_frequency: str, + expectation, +) -> None: + with expectation: + _final_validate_config(set_core_config, config, boot_frequency=boot_frequency) + + +def test_rejects_power_down_flash_with_psram( + set_core_config: SetCoreConfigCallable, +) -> None: + with pytest.raises(cv.Invalid, match="not allowed when the device has PSRAM"): + _final_validate_config( + set_core_config, + LIGHT_SLEEP | {CONF_POWER_DOWN_FLASH: True}, + {"psram": {"mode": "quad"}}, + ) + + +@pytest.fixture +def sdkconfig_calls() -> dict[str, object]: + """Run to_code() without codegen and collect the sdkconfig options it sets.""" + calls: dict[str, object] = {} + with ( + mock.patch( + "esphome.components.esp32_power_management.power_management.cg.new_Pvariable", + return_value=mock.MagicMock(), + ), + mock.patch( + "esphome.components.esp32_power_management.power_management.power_management." + "register_power_management", + new_callable=mock.AsyncMock, + ), + mock.patch( + "esphome.components.esp32_power_management.power_management.add_idf_sdkconfig_option", + side_effect=calls.__setitem__, + ), + ): + yield calls + + +SLEEPY_MTD = { + CONF_OPENTHREAD: { + CONF_DEVICE_TYPE: "MTD", + CONF_POLL_PERIOD: TimePeriodMilliseconds(milliseconds=1000), + } +} +NOT_SET = object() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("core_config", "light_sleep", "key", "expected"), + [ + pytest.param(SLEEPY_MTD, True, "CONFIG_LWIP_ND6", False, id="nd6_sleepy_mtd"), + pytest.param( + {CONF_OPENTHREAD: {CONF_DEVICE_TYPE: "FTD"}}, + True, + "CONFIG_LWIP_ND6", + NOT_SET, + id="nd6_ftd", + ), + pytest.param({}, True, "CONFIG_LWIP_ND6", NOT_SET, id="nd6_no_openthread"), + pytest.param( + {CONF_OPENTHREAD: {CONF_DEVICE_TYPE: "FTD"}}, + True, + "CONFIG_IEEE802154_SLEEP_ENABLE", + True, + id="radio_sleep_openthread", + ), + pytest.param( + {"zigbee": {"id": "zigbee_id"}}, + True, + "CONFIG_IEEE802154_SLEEP_ENABLE", + True, + id="radio_sleep_zigbee", + ), + pytest.param( + {}, True, "CONFIG_IEEE802154_SLEEP_ENABLE", NOT_SET, id="radio_sleep_none" + ), + pytest.param( + SLEEPY_MTD, False, "CONFIG_LWIP_ND6", NOT_SET, id="nd6_light_sleep_off" + ), + pytest.param( + SLEEPY_MTD, + False, + "CONFIG_IEEE802154_SLEEP_ENABLE", + NOT_SET, + id="radio_sleep_light_sleep_off", + ), + ], +) +async def test_to_code_sdkconfig( + set_core_config: SetCoreConfigCallable, + sdkconfig_calls: dict[str, object], + core_config: ConfigType, + light_sleep: bool, + key: str, + expected: object, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32H2) + CORE.config = core_config + await to_code(CONFIG_SCHEMA({CONF_ENABLE_LIGHT_SLEEP: light_sleep})) + assert sdkconfig_calls.get(key, NOT_SET) is expected diff --git a/tests/components/esp32_power_management/common.yaml b/tests/components/esp32_power_management/common.yaml new file mode 100644 index 00000000000..74d93e6577f --- /dev/null +++ b/tests/components/esp32_power_management/common.yaml @@ -0,0 +1,11 @@ +power_management: + - platform: esp32_power_management + id: pm_id + max_frequency: ${esp32_power_management_max_frequency} + min_frequency: ${esp32_power_management_min_frequency} + enable_light_sleep: true + idle_time_before_sleep: 3 + power_down_flash: true + power_down_peripherals: ${esp32_power_management_power_down_peripherals} + profiling: true + trace: true diff --git a/tests/components/esp32_power_management/test.esp32-ard.yaml b/tests/components/esp32_power_management/test.esp32-ard.yaml new file mode 100644 index 00000000000..2b25b2d23f9 --- /dev/null +++ b/tests/components/esp32_power_management/test.esp32-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + esp32_power_management_max_frequency: 160MHZ + esp32_power_management_min_frequency: 40MHZ + esp32_power_management_power_down_peripherals: "false" + +packages: + esp32_power_management: !include common.yaml diff --git a/tests/components/esp32_power_management/test.esp32-h2-idf.yaml b/tests/components/esp32_power_management/test.esp32-h2-idf.yaml new file mode 100644 index 00000000000..c571a2be0cd --- /dev/null +++ b/tests/components/esp32_power_management/test.esp32-h2-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + esp32_power_management_max_frequency: 96MHZ + esp32_power_management_min_frequency: 32MHZ + esp32_power_management_power_down_peripherals: "true" + +packages: + esp32_power_management: !include common.yaml diff --git a/tests/components/esp32_power_management/test.esp32-idf.yaml b/tests/components/esp32_power_management/test.esp32-idf.yaml new file mode 100644 index 00000000000..2b25b2d23f9 --- /dev/null +++ b/tests/components/esp32_power_management/test.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + esp32_power_management_max_frequency: 160MHZ + esp32_power_management_min_frequency: 40MHZ + esp32_power_management_power_down_peripherals: "false" + +packages: + esp32_power_management: !include common.yaml