mirror of
https://github.com/esphome/esphome.git
synced 2026-10-06 10:56:43 +00:00
[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:
co-authored by
Jonathan Swoboda
pre-commit-ci-lite[bot]
J. Nick Koston
parent
4c9a44301d
commit
1440998f3c
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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_();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
esphome:
|
||||
debug_scheduler: true
|
||||
loop_interval: 50ms
|
||||
suspend_loop: false
|
||||
platformio_options:
|
||||
board_build.flash_mode: dio
|
||||
build_flags:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user