Merge remote-tracking branch 'upstream/decouple_scheduler_loop_cadence' into integration

# Conflicts:
#	esphome/core/application.h
This commit is contained in:
J. Nick Koston
2026-04-18 07:12:36 -05:00
6 changed files with 234 additions and 57 deletions
@@ -0,0 +1,60 @@
esphome:
name: loop-interval-decouple
on_boot:
priority: -100
then:
- lambda: |-
// Raise loop_interval_ to 500ms. With the decoupling fix the
// component phase should run ~twice per second while the 50ms
// scheduler interval below still fires at its requested cadence.
App.set_loop_interval(500);
# Start measurement after 1s so boot transients settle.
- delay: 1000ms
- lambda: |-
id(loop_at_start) = id(loop_counter)->get_loop_count();
id(sched_at_start) = id(sched_count);
ESP_LOGI("test", "MEASUREMENT_STARTED loop=%d sched=%d",
id(loop_at_start), id(sched_at_start));
# Observe for 2s.
- delay: 2000ms
- lambda: |-
int loop_delta = id(loop_counter)->get_loop_count() - id(loop_at_start);
int sched_delta = id(sched_count) - id(sched_at_start);
ESP_LOGI("test", "MEASUREMENT_DONE loop_delta=%d sched_delta=%d",
loop_delta, sched_delta);
host:
api:
logger:
level: INFO
logs:
loop_test_component: WARN # Silence per-loop log spam
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
globals:
- id: sched_count
type: int
initial_value: "0"
- id: loop_at_start
type: int
initial_value: "0"
- id: sched_at_start
type: int
initial_value: "0"
loop_test_component:
components:
- id: loop_counter
name: loop_counter
interval:
# Fast scheduler interval — with the decoupling fix this should fire at
# its requested 50ms cadence regardless of loop_interval_.
- interval: 50ms
then:
- lambda: |-
id(sched_count) += 1;
@@ -0,0 +1,73 @@
"""Test that loop_interval_ no longer clamps scheduler cadence.
Regression test for the decoupling of Application::loop() component-phase
cadence from scheduler wake timing.
Setup:
- App.set_loop_interval(500) — raised for power-savings style cadence
- Scheduler interval at 50ms — should fire at 50ms regardless of loop_interval_
- Component loop (LoopTestComponent) — should run at 500ms cadence
Before the decoupling fix the old `std::max(next_schedule, delay_time / 2)`
floor clamped the sleep to ~250ms, so the 50ms scheduler only fired ~8 times
per 2s (vs the ~40 expected). After the fix the scheduler fires close to its
requested cadence while the component phase stays gated at loop_interval_.
"""
from __future__ import annotations
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_loop_interval_decoupling(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Raised loop_interval_ must not clamp scheduler item cadence."""
loop = asyncio.get_running_loop()
measurement_done: asyncio.Future[tuple[int, int]] = loop.create_future()
def on_log_line(line: str) -> None:
match = re.search(r"MEASUREMENT_DONE loop_delta=(\d+) sched_delta=(\d+)", line)
if match and not measurement_done.done():
measurement_done.set_result((int(match.group(1)), int(match.group(2))))
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "loop-interval-decouple"
try:
loop_delta, sched_delta = await asyncio.wait_for(
measurement_done, timeout=10.0
)
except TimeoutError:
pytest.fail("MEASUREMENT_DONE marker never appeared")
# Observation window = 2s, loop_interval_ = 500ms.
# Component phase should fire ~4 times in 2s. The upper bound must be
# less than 8: the pre-decoupling behavior clamped to ~250ms cadence
# giving ~8 loops/2s, so allowing 8 would let the old behavior pass.
assert 2 <= loop_delta <= 6, (
f"Component loop should fire ~4 times in 2s at loop_interval=500ms, "
f"got {loop_delta}"
)
# Scheduler interval = 50ms → ~40 fires in 2s. Before the decoupling
# fix this clamped to ~8 fires. Assert >= 20 to catch the old clamped
# behavior with comfortable jitter headroom for slow CI hosts.
assert sched_delta >= 20, (
f"50ms scheduler interval should fire ~40 times in 2s but only "
f"fired {sched_delta}. This indicates loop_interval_ is still "
f"clamping scheduler cadence."
)