[core] Add new options suspend_loop and loop_interval (#18136)

Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
luar123
2026-10-02 09:09:14 -05:00
committed by GitHub
co-authored by Jonathan Swoboda pre-commit-ci-lite[bot] J. Nick Koston
parent 4c9a44301d
commit 1440998f3c
15 changed files with 255 additions and 9 deletions
+1
View File
@@ -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)
+38 -6
View File
@@ -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<uint32_t>::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<uint32_t>::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_();
}
+63
View File
@@ -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)
+4
View File
@@ -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
+2
View File
@@ -1,5 +1,7 @@
esphome:
debug_scheduler: true
loop_interval: 50ms
suspend_loop: false
platformio_options:
board_build.flash_mode: dio
build_flags:
+5 -1
View File
@@ -1 +1,5 @@
<<: !include common.yaml
packages:
base: !include common.yaml
esphome:
suspend_loop: true
@@ -1 +1,5 @@
<<: !include common.yaml
packages:
base: !include common.yaml
esphome:
suspend_loop: true
@@ -1 +1,5 @@
<<: !include common.yaml
packages:
base: !include common.yaml
esphome:
suspend_loop: true
+92
View File
@@ -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(
@@ -0,0 +1,7 @@
esphome:
name: test
suspend_loop: true
loop_interval: 5000ms
bk72xx:
board: generic-bk7252
@@ -0,0 +1,7 @@
esphome:
name: test
loop_interval: 7s
esp32:
board: esp32dev
watchdog_timeout: 6s
@@ -0,0 +1,6 @@
esphome:
name: test
loop_interval: 700ms
nrf52:
board: adafruit_feather_nrf52840
@@ -0,0 +1,9 @@
esphome:
name: test
debug_scheduler: true
suspend_loop: true
loop_interval: 50ms
esp32:
board: esp32dev
watchdog_timeout: 6s
@@ -0,0 +1,5 @@
esphome:
name: test
suspend_loop: true
host:
@@ -0,0 +1,6 @@
esphome:
name: test
suspend_loop: true
rp2:
board: rpipicow