diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 2b820c9be6c..5f340c60efe 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -143,6 +143,8 @@ def validate_wakeup_pin( processed_pins: list[ConfigType] = [{CONF_PIN: value}] else: processed_pins = list(value) + if not processed_pins: + raise cv.Invalid("'wakeup_pin' needs at least one entry") for i, pin_config in enumerate(processed_pins): # now validate each item @@ -154,29 +156,34 @@ def validate_wakeup_pin( def validate_config(config: ConfigType) -> ConfigType: - # right now only BK72XX supports the list format for wakeup pins - if CORE.is_bk72xx: - if CONF_WAKEUP_PIN_MODE in config: - wakeup_pins = config.get(CONF_WAKEUP_PIN, []) - if len(wakeup_pins) > 1: - raise cv.Invalid( - "You need to remove the global wakeup_pin_mode and define it per pin" - ) - if wakeup_pins: - if CONF_WAKEUP_PIN_MODE in wakeup_pins[0]: - raise cv.Invalid( - "Specify wakeup_pin_mode either at the top level under deep_sleep " - "or under the pin entry, not both" - ) - wakeup_pins[0][CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE) - elif ( - isinstance(config.get(CONF_WAKEUP_PIN), list) - and len(config[CONF_WAKEUP_PIN]) > 1 - ): + wakeup_pins = config.get(CONF_WAKEUP_PIN) + has_pin_mode = CONF_WAKEUP_PIN_MODE in config + if has_pin_mode and wakeup_pins is None: raise cv.Invalid( - "Your platform does not support providing multiple entries in wakeup_pin" + "'wakeup_pin_mode' requires 'wakeup_pin'", path=[CONF_WAKEUP_PIN_MODE] ) - + multiple_pins = wakeup_pins is not None and len(wakeup_pins) > 1 + # wakeup_pin is limited to ESP32 and BK72XX, and only BK72XX accepts a list + if not CORE.is_bk72xx: + if multiple_pins: + raise cv.Invalid( + "ESP32 does not support multiple entries for 'wakeup_pin'", + path=[CONF_WAKEUP_PIN], + ) + elif has_pin_mode: + if multiple_pins: + raise cv.Invalid( + "Remove the global 'wakeup_pin_mode' and define it per pin", + path=[CONF_WAKEUP_PIN_MODE], + ) + if CONF_WAKEUP_PIN_MODE in (pin := wakeup_pins[0]): + raise cv.Invalid( + "Specify 'wakeup_pin_mode' either at the top level under 'deep_sleep' " + "or under the pin entry, not both", + path=[CONF_WAKEUP_PIN, 0, CONF_WAKEUP_PIN_MODE], + ) + # Move the global mode into the single pin entry, which to_code() reads + pin[CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE) return config @@ -203,11 +210,11 @@ def _validate_sleep_duration(value: core.TimePeriod) -> core.TimePeriod: if CORE.is_bk72xx: max_duration = core.TimePeriod(hours=36) if value > max_duration: - raise cv.Invalid("sleep duration cannot be more than 36 hours on BK72XX") + raise cv.Invalid("'sleep_duration' cannot be more than 36 hours on BK72XX") elif CORE.using_zephyr: max_duration = core.TimePeriod(days=49) if value > max_duration: - raise cv.Invalid("sleep duration cannot be more than 49 days on Zephyr") + raise cv.Invalid("'sleep_duration' cannot be more than 49 days on Zephyr") return value @@ -290,7 +297,10 @@ CONFIG_SCHEMA = cv.All( cv.positive_time_period_milliseconds, _validate_sleep_duration, ), - cv.Optional(CONF_WAKEUP_PIN): validate_wakeup_pin, + cv.Optional(CONF_WAKEUP_PIN): cv.All( + cv.only_on([PLATFORM_ESP32, PLATFORM_BK72XX]), + validate_wakeup_pin, + ), cv.Optional(CONF_WAKEUP_PIN_MODE): cv.All( cv.only_on([PLATFORM_ESP32, PLATFORM_BK72XX]), cv.enum(WAKEUP_PIN_MODES, upper=True), @@ -347,11 +357,10 @@ async def to_code(config: ConfigType) -> None: if CONF_SLEEP_DURATION in config: cg.add(var.set_sleep_duration(config[CONF_SLEEP_DURATION])) - if CONF_WAKEUP_PIN in config: - pins_as_list = config.get(CONF_WAKEUP_PIN, []) + if (wakeup_pins := config.get(CONF_WAKEUP_PIN)) is not None: if CORE.is_bk72xx: - cg.add(var.init_wakeup_pins(len(pins_as_list))) - for item in pins_as_list: + cg.add(var.init_wakeup_pins(len(wakeup_pins))) + for item in wakeup_pins: cg.add( var.add_wakeup_pin( await cg.gpio_pin_expression(item[CONF_PIN]), @@ -361,7 +370,7 @@ async def to_code(config: ConfigType) -> None: ) ) else: - pin = await cg.gpio_pin_expression(pins_as_list[0][CONF_PIN]) + pin = await cg.gpio_pin_expression(wakeup_pins[0][CONF_PIN]) cg.add(var.set_wakeup_pin(pin)) if CONF_WAKEUP_PIN_MODE in config: cg.add(var.set_wakeup_pin_mode(config[CONF_WAKEUP_PIN_MODE])) diff --git a/tests/component_tests/deep_sleep/test_deep_sleep.py b/tests/component_tests/deep_sleep/test_deep_sleep.py index e68b1d17cc1..e05109f246a 100644 --- a/tests/component_tests/deep_sleep/test_deep_sleep.py +++ b/tests/component_tests/deep_sleep/test_deep_sleep.py @@ -123,3 +123,66 @@ def test_deep_sleep_bk72xx_top_level_wakeup_pin_mode_moved_onto_single_pin( assert ( result[CONF_WAKEUP_PIN][0][deep_sleep.CONF_WAKEUP_PIN_MODE] == "INVERT_WAKEUP" ) + + +def test_deep_sleep_empty_wakeup_pin_list_rejected() -> None: + """An empty wakeup_pin list is rejected at validation instead of crashing codegen.""" + with pytest.raises(cv.Invalid, match="at least one entry"): + deep_sleep.validate_wakeup_pin([]) + + +def test_deep_sleep_bk72xx_wakeup_pin_mode_without_wakeup_pin_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On BK72xx, a top-level wakeup_pin_mode with no wakeup_pin is rejected at validation.""" + set_core_config(PlatformFramework.BK72XX_ARDUINO) + config = {deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"} + with pytest.raises(cv.Invalid, match="requires 'wakeup_pin'"): + deep_sleep.validate_config(config) + + +def test_deep_sleep_esp32_wakeup_pin_mode_without_wakeup_pin_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On ESP32 the same mode-only config is rejected too, instead of being silently dead.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = {deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"} + with pytest.raises(cv.Invalid, match="requires 'wakeup_pin'"): + deep_sleep.validate_config(config) + + +@pytest.mark.parametrize( + "platform", [PlatformFramework.ESP8266_ARDUINO, PlatformFramework.NRF52_ZEPHYR] +) +def test_deep_sleep_wakeup_pin_rejected_on_unsupported_platform( + set_core_config: SetCoreConfigCallable, platform: PlatformFramework +) -> None: + """wakeup_pin is rejected where the C++ component has no wakeup pin support.""" + set_core_config(platform) + with pytest.raises(cv.Invalid, match="only available on"): + deep_sleep.CONFIG_SCHEMA({CONF_WAKEUP_PIN: "GPIO4"}) + + +def test_deep_sleep_esp32_multiple_wakeup_pins_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """ESP32 accepts only one wakeup_pin entry.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = {CONF_WAKEUP_PIN: [{"pin": "GPIO4"}, {"pin": "GPIO5"}]} + with pytest.raises(cv.Invalid, match="ESP32 does not support multiple") as exc: + deep_sleep.validate_config(config) + assert exc.value.path == [CONF_WAKEUP_PIN] + + +def test_deep_sleep_bk72xx_global_wakeup_pin_mode_with_multiple_pins_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On BK72xx, a top-level wakeup_pin_mode cannot be applied to several pins.""" + set_core_config(PlatformFramework.BK72XX_ARDUINO) + config = { + CONF_WAKEUP_PIN: [{"pin": "GPIO12"}, {"pin": "GPIO13"}], + deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE", + } + with pytest.raises(cv.Invalid, match="define it per pin") as exc: + deep_sleep.validate_config(config) + assert exc.value.path == [deep_sleep.CONF_WAKEUP_PIN_MODE]