diff --git a/esphome/config.py b/esphome/config.py index 13ec744ce4..bc69661d8f 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1226,6 +1226,7 @@ class CoreFinalValidateStep(ConfigValidationStep): with result.catch_error([CONF_ESPHOME]): if CONF_ESPHOME in result: core_config.validate_ids_and_references(result[CONF_ESPHOME]) + core_config.validate_loop_interval(result[CONF_ESPHOME]) fv.full_config.reset(token) diff --git a/esphome/core/application.h b/esphome/core/application.h index 8ed4c09096..2f399cbdcd 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -208,8 +208,8 @@ class Application { * Each component can request a high frequency loop execution by using the HighFrequencyLoopRequester * helper in helpers.h * - * Note: This method is not called by ESPHome core code. It is only used by lambda functions - * in YAML configurations or by external components. + * Sleep per wake is capped at 2 * WDT_FEED_INTERVAL_MS (except host and ESP8266); + * raise the platform watchdog timeout to sleep longer. * * @param loop_interval The interval in milliseconds to run the core loop at. Defaults to 16 milliseconds. */ @@ -232,6 +232,7 @@ class Application { /// - ESP8266 soft WDT (~1.6 s): ~16x <-- 100 ms feed (see USE_ESP8266 below) /// - ESP8266 HW WDT (~6 s): ~60x /// - BK72xx HW WDT (10 s): ~5x <-- platform override below + /// Important: if these are modified align validate_loop_interval in config.py #ifdef USE_BK72XX // BDK busy-waits 200us per WDT reload (sctrl_dpll_delay200us). LibreTiny // sets HW WDT to 10s; 2000ms keeps ~5x margin. See wdt_ctrl WCMD_RELOAD_PERIOD: @@ -775,8 +776,8 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { } #endif - // Compute sleep: bounded by time-until-next-component-phase and the - // scheduler's next deadline. When a scheduler timer fires it re-enters + // Compute sleep: bounded by time-until-next-component-phase if there are + // components with loop enabled and the scheduler's next deadline. When a scheduler timer fires it re-enters // loop(), Phase A services it, and the component phase stays gated by // loop_interval_. When a background producer calls wake_loop_threadsafe() // it sets the wake_request flag and wakes select() / the task notification; @@ -795,16 +796,47 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { uint32_t delay_time = 0; if (!HighFrequencyLoopRequester::is_high_frequency()) { const uint32_t elapsed_since_phase = now - this->last_loop_; - const uint32_t until_phase = +#ifdef ESPHOME_SUSPEND_LOOP + const bool has_loop_work = + this->looping_components_active_end_ > 0 || this->dump_config_at_ < this->components_.size(); + uint32_t until_phase = std::numeric_limits::max(); + if (has_loop_work) { + until_phase = (elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase); + } +#else + uint32_t until_phase = (elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase); +#endif const uint32_t until_sched = this->scheduler.next_schedule_in(now).value_or(until_phase); delay_time = std::min(until_phase, until_sched); } // All platforms route loop yields through the platform wake primitive. // On host this drains the loopback wake socket via select(); on FreeRTOS // targets it uses task notifications; on ESP8266/RP2040 it uses esp_delay/WFE. - esphome::internal::wakeable_delay(delay_time); + // Cap the sleep so the WDT feed and status-LED dispatch rate limits still get + // exercised even when loop_interval is raised or the scheduler and component + // phases are gated out for a long sleep. Waking every 2*WDT_FEED_INTERVAL_MS + // clears the feed rate limit on every wake, so the WDT is fed at least that + // often -- well inside every platform's timeout. +#if defined(USE_ESP8266) + // SDK os_timer_arm() accepts at most 0x68D7A3 ms without system_timer_reinit(); + // the SDK feeds both watchdogs while the cont task is suspended, so no WDT cap needed. + static constexpr uint32_t MAX_SLEEP_BASE = 0x68D7A3; +#elif defined(USE_HOST) + // arch_feed_wdt() is a no-op on host and ESPHOME_SUSPEND_LOOP is rejected by + // the config validator, so delay_time is already bounded by loop_interval_. + static constexpr uint32_t MAX_SLEEP_BASE = std::numeric_limits::max(); +#else + static constexpr uint32_t MAX_SLEEP_BASE = WDT_FEED_INTERVAL_MS * 2; +#endif + uint32_t max_sleep = MAX_SLEEP_BASE; +#ifdef USE_STATUS_LED + if ((this->app_state_ & STATUS_LED_MASK) != 0) { + max_sleep = std::min(max_sleep, STATUS_LED_DISPATCH_INTERVAL_MS); + } +#endif + esphome::internal::wakeable_delay(std::min(delay_time, max_sleep)); if (this->dump_config_at_ < this->components_.size()) { this->process_dump_config_(); } diff --git a/esphome/core/config.py b/esphome/core/config.py index 532ca7c36d..531a027647 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -43,7 +43,11 @@ from esphome.const import ( CONF_PROJECT, CONF_TRIGGER_ID, CONF_VERSION, + CONF_WATCHDOG_TIMEOUT, KEY_CORE, + PLATFORM_ESP32, + PLATFORM_HOST, + PLATFORM_RP2, PlatformFramework, __version__ as ESPHOME_VERSION, ) @@ -53,6 +57,7 @@ from esphome.core import ( CoroPriority, coroutine_with_priority, ) +import esphome.final_validate as fv from esphome.helpers import ( copy_file_if_changed, cpp_string_escape, @@ -63,6 +68,9 @@ from esphome.helpers import ( ) from esphome.types import ConfigType +CONF_LOOP_INTERVAL = "loop_interval" +CONF_SUSPEND_LOOP = "suspend_loop" + _LOGGER = logging.getLogger(__name__) # C++ variable names and separators for app name buffers (used with MAC suffix) @@ -186,6 +194,35 @@ def validate_ids_and_references(config: ConfigType) -> ConfigType: return config +def validate_loop_interval(config: ConfigType) -> ConfigType: + if CONF_LOOP_INTERVAL in config and not (CORE.is_host or CORE.is_esp8266): + # max_loop interval is calculated from WDT_FEED_INTERVAL_MS. Make sure to align with application.h + max_loop = 600 # 2 * 300ms default for other platforms + if CORE.is_esp32: + max_loop = ( + 2 + * fv.full_config.get()[PLATFORM_ESP32][ + CONF_WATCHDOG_TIMEOUT + ].total_milliseconds + // 5 + ) + elif CORE.is_bk72xx: + max_loop = 4000 # 10000ms / 5 * 2 default value + if config[CONF_LOOP_INTERVAL].total_milliseconds > max_loop: + _LOGGER.warning( + "%s of %s exceeds the %sms maximum sleep on this platform; the loop will still " + "wake every %sms.%s", + CONF_LOOP_INTERVAL, + config[CONF_LOOP_INTERVAL], + max_loop, + max_loop, + " Raise esp32.watchdog_timeout to sleep longer." + if CORE.is_esp32 + else "", + ) + return config + + def valid_include(value: str) -> str: # Look for "<...>" includes if value.startswith("<") and value.endswith(">"): @@ -266,6 +303,15 @@ def validate_area_config(config: dict | str) -> dict[str, str | core.ID]: return cv.maybe_simple_value(AREA_SCHEMA, key=CONF_NAME)(config) +def _validate_suspend_loop(value: bool) -> bool: + # host and RP2 platforms have unwakeable delay fallbacks, so suspending the main loop is unsafe + if value and CORE.target_platform in [PLATFORM_HOST, PLATFORM_RP2]: + raise cv.Invalid( + f"Suspend loop is not available on {CORE.target_platform} platform" + ) + return value + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -331,6 +377,19 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_DEBUG_SCHEDULER, default=False, visibility=cv.Visibility.YAML_ONLY ): cv.boolean, + cv.Optional(CONF_LOOP_INTERVAL, visibility=cv.Visibility.YAML_ONLY): cv.All( + cv.positive_time_period_milliseconds, + cv.Range( + min=cv.TimePeriod(milliseconds=1), + max=cv.TimePeriod(milliseconds=65535), + ), + ), + cv.Optional( + CONF_SUSPEND_LOOP, default=False, visibility=cv.Visibility.YAML_ONLY + ): cv.All( + cv.boolean, + _validate_suspend_loop, + ), cv.Optional(CONF_PROJECT): cv.Schema( { cv.Required(CONF_NAME): cv.All( @@ -806,6 +865,10 @@ async def to_code(config: ConfigType) -> None: cg.add_cxx_build_flag("-Wno-volatile") if config[CONF_DEBUG_SCHEDULER]: cg.add_define("ESPHOME_DEBUG_SCHEDULER") + if config[CONF_SUSPEND_LOOP]: + cg.add_define("ESPHOME_SUSPEND_LOOP") + if CONF_LOOP_INTERVAL in config: + cg.add(cg.App.set_loop_interval(config[CONF_LOOP_INTERVAL])) if CORE.using_arduino: CORE.add_job(add_arduino_global_workaround) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 558bbd54cd..21c4e3d996 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -403,6 +403,7 @@ #define ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT 2 #define ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS #define ESPHOME_LOOP_TASK_STACK_SIZE 8192 +#define ESPHOME_SUSPEND_LOOP #define USE_ESP32_CAMERA_JPEG_ENCODER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C @@ -520,6 +521,7 @@ // ESP8266-specific feature flags #ifdef USE_ESP8266 +#define ESPHOME_SUSPEND_LOOP #define USE_ADC_SENSOR_VCC #define USE_ESP8266_CRASH_HANDLER #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 1, 2) @@ -597,6 +599,7 @@ #define USE_BK72XX_BLE_TRACKER #endif #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 +#define ESPHOME_SUSPEND_LOOP #define USE_BLE_SCAN_RESPONSE_MERGER #define USE_CAPTIVE_PORTAL #define USE_WIFI_SCAN_RESULTS_LOCK @@ -622,6 +625,7 @@ #ifdef USE_NRF52 #define ESPHOME_BLE_NUS_TX_RING_BUFFER_SIZE 512 #define ESPHOME_BLE_NUS_RX_RING_BUFFER_SIZE 512 +#define ESPHOME_SUSPEND_LOOP #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 #define USE_LOGGER_EARLY_MESSAGE diff --git a/tests/components/esphome/common.yaml b/tests/components/esphome/common.yaml index 93f82824e6..fd3d6a0efe 100644 --- a/tests/components/esphome/common.yaml +++ b/tests/components/esphome/common.yaml @@ -1,5 +1,7 @@ esphome: debug_scheduler: true + loop_interval: 50ms + suspend_loop: false platformio_options: board_build.flash_mode: dio build_flags: diff --git a/tests/components/esphome/test.esp32-idf.yaml b/tests/components/esphome/test.esp32-idf.yaml index dade44d145..7c571c7f51 100644 --- a/tests/components/esphome/test.esp32-idf.yaml +++ b/tests/components/esphome/test.esp32-idf.yaml @@ -1 +1,5 @@ -<<: !include common.yaml +packages: + base: !include common.yaml + +esphome: + suspend_loop: true diff --git a/tests/components/esphome/test.esp8266-ard.yaml b/tests/components/esphome/test.esp8266-ard.yaml index dade44d145..7c571c7f51 100644 --- a/tests/components/esphome/test.esp8266-ard.yaml +++ b/tests/components/esphome/test.esp8266-ard.yaml @@ -1 +1,5 @@ -<<: !include common.yaml +packages: + base: !include common.yaml + +esphome: + suspend_loop: true diff --git a/tests/components/esphome/test.nrf52-adafruit.yaml b/tests/components/esphome/test.nrf52-adafruit.yaml index dade44d145..7c571c7f51 100644 --- a/tests/components/esphome/test.nrf52-adafruit.yaml +++ b/tests/components/esphome/test.nrf52-adafruit.yaml @@ -1 +1,5 @@ -<<: !include common.yaml +packages: + base: !include common.yaml + +esphome: + suspend_loop: true diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 7fbc2d1b39..1a36fdc967 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -26,6 +26,7 @@ from esphome.const import ( ) from esphome.core import CORE, KEY_CONTROLLER_REGISTRY_CONTROLLERS, config from esphome.core.config import ( + CONF_SUSPEND_LOOP, Area, make_app_name_cpp, preload_core_config, @@ -248,6 +249,97 @@ def test_area_id_collision( assert "ID duplicate_id redefined! Check esphome->area->id." in captured.out +@pytest.mark.parametrize( + ("fixture", "expected_platform"), + [ + ("suspend_loop_host.yaml", "host"), + ("suspend_loop_rp2.yaml", "rp2"), + ], +) +def test_suspend_loop_fail( + yaml_file: Callable[[str], str], + capsys: pytest.CaptureFixture[str], + fixture: str, + expected_platform: str, +) -> None: + """Test that suspend_loop fails.""" + result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR) + assert result is None + + # Check for the specific error message in stdout + captured = capsys.readouterr() + assert ( + f"Suspend loop is not available on {expected_platform} platform" in captured.out + ) + + +def test_loop_interval_warn_esp32( + yaml_file: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that too high loop_interval prints warning.""" + result = load_config_from_fixture( + yaml_file, "loop_interval_esp32.yaml", FIXTURES_DIR + ) + assert result is not None + + assert ( + "loop_interval of 7s exceeds the 2400ms maximum sleep on this platform; the loop will still " + "wake every 2400ms. Raise esp32.watchdog_timeout to sleep longer." + in caplog.text + ) + + +@pytest.mark.parametrize( + ("fixture", "interval", "max_loop"), + [ + ("loop_interval_bk72xx.yaml", "5000ms", "4000"), + ("loop_interval_nrf52.yaml", "700ms", "600"), + ], +) +def test_loop_interval_warn( + yaml_file: Callable[[str], str], + caplog: pytest.LogCaptureFixture, + fixture: str, + interval: str, + max_loop: str, +) -> None: + """Test that too high loop_interval prints warning.""" + result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR) + assert result is not None + + assert ( + f"loop_interval of {interval} exceeds the {max_loop}ms maximum sleep on this platform; the loop will still " + f"wake every {max_loop}ms." in caplog.text + ) + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::RuntimeWarning") +async def test_suspend_loop_and_loop_interval( + yaml_file: Callable[[str], Path], +) -> None: + """Test suspend_loop and loop_interval on esp32""" + result = load_config_from_fixture( + yaml_file, "suspend_loop_esp32.yaml", FIXTURES_DIR + ) + assert result is not None + + esphome_config = result["esphome"] + assert esphome_config.get(CONF_SUSPEND_LOOP) + + with patch("esphome.core.config.cg") as mock_cg: + mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() + mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock() + await config.to_code(result[CONF_ESPHOME]) + + mock_cg.add_define.assert_any_call("ESPHOME_SUSPEND_LOOP") + mock_cg.add_define.assert_any_call("ESPHOME_DEBUG_SCHEDULER") + mock_cg.App.set_loop_interval.assert_called_once_with( + cv.TimePeriodMilliseconds(milliseconds=50) + ) + + def test_device_without_area(yaml_file: Callable[[str], str]) -> None: """Test that devices without area_id work correctly.""" result = load_config_from_fixture( diff --git a/tests/unit_tests/fixtures/core/config/loop_interval_bk72xx.yaml b/tests/unit_tests/fixtures/core/config/loop_interval_bk72xx.yaml new file mode 100644 index 0000000000..a29a8e4c93 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/loop_interval_bk72xx.yaml @@ -0,0 +1,7 @@ +esphome: + name: test + suspend_loop: true + loop_interval: 5000ms + +bk72xx: + board: generic-bk7252 diff --git a/tests/unit_tests/fixtures/core/config/loop_interval_esp32.yaml b/tests/unit_tests/fixtures/core/config/loop_interval_esp32.yaml new file mode 100644 index 0000000000..491b14d464 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/loop_interval_esp32.yaml @@ -0,0 +1,7 @@ +esphome: + name: test + loop_interval: 7s + +esp32: + board: esp32dev + watchdog_timeout: 6s diff --git a/tests/unit_tests/fixtures/core/config/loop_interval_nrf52.yaml b/tests/unit_tests/fixtures/core/config/loop_interval_nrf52.yaml new file mode 100644 index 0000000000..9dd38bf4da --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/loop_interval_nrf52.yaml @@ -0,0 +1,6 @@ +esphome: + name: test + loop_interval: 700ms + +nrf52: + board: adafruit_feather_nrf52840 diff --git a/tests/unit_tests/fixtures/core/config/suspend_loop_esp32.yaml b/tests/unit_tests/fixtures/core/config/suspend_loop_esp32.yaml new file mode 100644 index 0000000000..ec18aa74b4 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/suspend_loop_esp32.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + debug_scheduler: true + suspend_loop: true + loop_interval: 50ms + +esp32: + board: esp32dev + watchdog_timeout: 6s diff --git a/tests/unit_tests/fixtures/core/config/suspend_loop_host.yaml b/tests/unit_tests/fixtures/core/config/suspend_loop_host.yaml new file mode 100644 index 0000000000..c75f4f8b4d --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/suspend_loop_host.yaml @@ -0,0 +1,5 @@ +esphome: + name: test + suspend_loop: true + +host: diff --git a/tests/unit_tests/fixtures/core/config/suspend_loop_rp2.yaml b/tests/unit_tests/fixtures/core/config/suspend_loop_rp2.yaml new file mode 100644 index 0000000000..8a2af21996 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/suspend_loop_rp2.yaml @@ -0,0 +1,6 @@ +esphome: + name: test + suspend_loop: true + +rp2: + board: rpipicow