1
0
mirror of https://github.com/esphome/esphome.git synced 2025-09-02 03:12:20 +01:00

Reduce CPU overhead by allowing components to disable their loop() (#9089)

This commit is contained in:
J. Nick Koston
2025-06-18 11:49:25 +02:00
committed by GitHub
parent fedb54bb38
commit 2e534ce41e
28 changed files with 646 additions and 29 deletions

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Generator
from collections.abc import AsyncGenerator, Callable, Generator
from contextlib import AbstractAsyncContextManager, asynccontextmanager
import logging
import os
@@ -46,6 +46,7 @@ if platform.system() == "Windows":
"Integration tests are not supported on Windows", allow_module_level=True
)
import pty # not available on Windows
@@ -362,7 +363,10 @@ async def api_client_connected(
async def _read_stream_lines(
stream: asyncio.StreamReader, lines: list[str], output_stream: TextIO
stream: asyncio.StreamReader,
lines: list[str],
output_stream: TextIO,
line_callback: Callable[[str], None] | None = None,
) -> None:
"""Read lines from a stream, append to list, and echo to output stream."""
log_parser = LogParser()
@@ -380,6 +384,9 @@ async def _read_stream_lines(
file=output_stream,
flush=True,
)
# Call the callback if provided
if line_callback:
line_callback(decoded_line.rstrip())
@asynccontextmanager
@@ -388,6 +395,7 @@ async def run_binary_and_wait_for_port(
host: str,
port: int,
timeout: float = PORT_WAIT_TIMEOUT,
line_callback: Callable[[str], None] | None = None,
) -> AsyncGenerator[None]:
"""Run a binary, wait for it to open a port, and clean up on exit."""
# Create a pseudo-terminal to make the binary think it's running interactively
@@ -435,7 +443,9 @@ async def run_binary_and_wait_for_port(
# Read from output stream
output_tasks = [
asyncio.create_task(
_read_stream_lines(output_reader, stdout_lines, sys.stdout)
_read_stream_lines(
output_reader, stdout_lines, sys.stdout, line_callback
)
)
]
@@ -515,6 +525,7 @@ async def run_compiled_context(
compile_esphome: CompileFunction,
port: int,
port_socket: socket.socket | None = None,
line_callback: Callable[[str], None] | None = None,
) -> AsyncGenerator[None]:
"""Context manager to write, compile and run an ESPHome configuration."""
# Write the YAML config
@@ -528,7 +539,9 @@ async def run_compiled_context(
port_socket.close()
# Run the binary and wait for the API server to start
async with run_binary_and_wait_for_port(binary_path, LOCALHOST, port):
async with run_binary_and_wait_for_port(
binary_path, LOCALHOST, port, line_callback=line_callback
):
yield
@@ -542,7 +555,9 @@ async def run_compiled(
port, port_socket = reserved_tcp_port
def _run_compiled(
yaml_content: str, filename: str | None = None
yaml_content: str,
filename: str | None = None,
line_callback: Callable[[str], None] | None = None,
) -> AbstractAsyncContextManager[asyncio.subprocess.Process]:
return run_compiled_context(
yaml_content,
@@ -551,6 +566,7 @@ async def run_compiled(
compile_esphome,
port,
port_socket,
line_callback=line_callback,
)
yield _run_compiled

View File

@@ -0,0 +1,78 @@
from esphome import automation
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_COMPONENTS, CONF_ID, CONF_NAME
CODEOWNERS = ["@esphome/tests"]
loop_test_component_ns = cg.esphome_ns.namespace("loop_test_component")
LoopTestComponent = loop_test_component_ns.class_("LoopTestComponent", cg.Component)
CONF_DISABLE_AFTER = "disable_after"
CONF_TEST_REDUNDANT_OPERATIONS = "test_redundant_operations"
COMPONENT_CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(LoopTestComponent),
cv.Required(CONF_NAME): cv.string,
cv.Optional(CONF_DISABLE_AFTER, default=0): cv.int_,
cv.Optional(CONF_TEST_REDUNDANT_OPERATIONS, default=False): cv.boolean,
}
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(LoopTestComponent),
cv.Required(CONF_COMPONENTS): cv.ensure_list(COMPONENT_CONFIG_SCHEMA),
}
).extend(cv.COMPONENT_SCHEMA)
# Define actions
EnableAction = loop_test_component_ns.class_("EnableAction", automation.Action)
DisableAction = loop_test_component_ns.class_("DisableAction", automation.Action)
@automation.register_action(
"loop_test_component.enable",
EnableAction,
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(LoopTestComponent),
}
),
)
async def enable_to_code(config, action_id, template_arg, args):
parent = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, parent)
return var
@automation.register_action(
"loop_test_component.disable",
DisableAction,
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(LoopTestComponent),
}
),
)
async def disable_to_code(config, action_id, template_arg, args):
parent = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, parent)
return var
async def to_code(config):
# The parent config doesn't actually create a component
# We just create each sub-component
for comp_config in config[CONF_COMPONENTS]:
var = cg.new_Pvariable(comp_config[CONF_ID])
await cg.register_component(var, comp_config)
cg.add(var.set_name(comp_config[CONF_NAME]))
cg.add(var.set_disable_after(comp_config[CONF_DISABLE_AFTER]))
cg.add(
var.set_test_redundant_operations(
comp_config[CONF_TEST_REDUNDANT_OPERATIONS]
)
)

View File

@@ -0,0 +1,43 @@
#include "loop_test_component.h"
namespace esphome {
namespace loop_test_component {
void LoopTestComponent::setup() { ESP_LOGI(TAG, "[%s] Setup called", this->name_.c_str()); }
void LoopTestComponent::loop() {
this->loop_count_++;
ESP_LOGI(TAG, "[%s] Loop count: %d", this->name_.c_str(), this->loop_count_);
// Test self-disable after specified count
if (this->disable_after_ > 0 && this->loop_count_ == this->disable_after_) {
ESP_LOGI(TAG, "[%s] Disabling self after %d loops", this->name_.c_str(), this->disable_after_);
this->disable_loop();
}
// Test redundant operations
if (this->test_redundant_operations_ && this->loop_count_ == 5) {
if (this->name_ == "redundant_enable") {
ESP_LOGI(TAG, "[%s] Testing enable when already enabled", this->name_.c_str());
this->enable_loop();
} else if (this->name_ == "redundant_disable") {
ESP_LOGI(TAG, "[%s] Testing disable when will be disabled", this->name_.c_str());
// We'll disable at count 10, but try to disable again at 5
this->disable_loop();
ESP_LOGI(TAG, "[%s] First disable complete", this->name_.c_str());
}
}
}
void LoopTestComponent::service_enable() {
ESP_LOGI(TAG, "[%s] Service enable called", this->name_.c_str());
this->enable_loop();
}
void LoopTestComponent::service_disable() {
ESP_LOGI(TAG, "[%s] Service disable called", this->name_.c_str());
this->disable_loop();
}
} // namespace loop_test_component
} // namespace esphome

View File

@@ -0,0 +1,58 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#include "esphome/core/automation.h"
namespace esphome {
namespace loop_test_component {
static const char *const TAG = "loop_test_component";
class LoopTestComponent : public Component {
public:
void set_name(const std::string &name) { this->name_ = name; }
void set_disable_after(int count) { this->disable_after_ = count; }
void set_test_redundant_operations(bool test) { this->test_redundant_operations_ = test; }
void setup() override;
void loop() override;
// Service methods for external control
void service_enable();
void service_disable();
int get_loop_count() const { return this->loop_count_; }
float get_setup_priority() const override { return setup_priority::DATA; }
protected:
std::string name_;
int loop_count_{0};
int disable_after_{0};
bool test_redundant_operations_{false};
};
template<typename... Ts> class EnableAction : public Action<Ts...> {
public:
EnableAction(LoopTestComponent *parent) : parent_(parent) {}
void play(Ts... x) override { this->parent_->service_enable(); }
protected:
LoopTestComponent *parent_;
};
template<typename... Ts> class DisableAction : public Action<Ts...> {
public:
DisableAction(LoopTestComponent *parent) : parent_(parent) {}
void play(Ts... x) override { this->parent_->service_disable(); }
protected:
LoopTestComponent *parent_;
};
} // namespace loop_test_component
} // namespace esphome

View File

@@ -0,0 +1,48 @@
esphome:
name: loop-test
host:
api:
logger:
level: DEBUG
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
loop_test_component:
components:
# Component that disables itself after 10 loops
- id: self_disable_10
name: "self_disable_10"
disable_after: 10
# Component that never disables itself (for re-enable test)
- id: normal_component
name: "normal_component"
disable_after: 0
# Component that tests enable when already enabled
- id: redundant_enable
name: "redundant_enable"
test_redundant_operations: true
disable_after: 0
# Component that tests disable when already disabled
- id: redundant_disable
name: "redundant_disable"
test_redundant_operations: true
disable_after: 10
# Interval to re-enable the self_disable_10 component after some time
interval:
- interval: 0.5s
then:
- if:
condition:
lambda: 'return id(self_disable_10).get_loop_count() == 10;'
then:
- logger.log: "Re-enabling self_disable_10 via service"
- loop_test_component.enable:
id: self_disable_10

View File

@@ -0,0 +1,150 @@
"""Integration test for loop disable/enable functionality."""
from __future__ import annotations
import asyncio
from pathlib import Path
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_loop_disable_enable(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that components can disable and enable their loop() method."""
# Get the absolute path to the external components directory
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
# Replace the placeholder in the YAML config with the actual path
yaml_config = yaml_config.replace(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
# Track log messages and events
log_messages: list[str] = []
# Event fired when self_disable_10 component disables itself after 10 loops
self_disable_10_disabled = asyncio.Event()
# Event fired when normal_component reaches 10 loops
normal_component_10_loops = asyncio.Event()
# Event fired when redundant_enable component tests enabling when already enabled
redundant_enable_tested = asyncio.Event()
# Event fired when redundant_disable component tests disabling when already disabled
redundant_disable_tested = asyncio.Event()
# Event fired when self_disable_10 component is re-enabled and runs again (count > 10)
self_disable_10_re_enabled = asyncio.Event()
# Track loop counts for components
self_disable_10_counts: list[int] = []
normal_component_counts: list[int] = []
def on_log_line(line: str) -> None:
"""Process each log line from the process output."""
# Strip ANSI color codes
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
if "loop_test_component" not in clean_line:
return
log_messages.append(clean_line)
# Track specific events using the cleaned line
if "[self_disable_10]" in clean_line:
if "Loop count:" in clean_line:
# Extract loop count
try:
count = int(clean_line.split("Loop count: ")[1])
self_disable_10_counts.append(count)
# Check if component was re-enabled (count > 10)
if count > 10:
self_disable_10_re_enabled.set()
except (IndexError, ValueError):
pass
elif "Disabling self after 10 loops" in clean_line:
self_disable_10_disabled.set()
elif "[normal_component]" in clean_line and "Loop count:" in clean_line:
try:
count = int(clean_line.split("Loop count: ")[1])
normal_component_counts.append(count)
if count >= 10:
normal_component_10_loops.set()
except (IndexError, ValueError):
pass
elif (
"[redundant_enable]" in clean_line
and "Testing enable when already enabled" in clean_line
):
redundant_enable_tested.set()
elif (
"[redundant_disable]" in clean_line
and "Testing disable when will be disabled" in clean_line
):
redundant_disable_tested.set()
# Write, compile and run the ESPHome device with log callback
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
# Verify we can connect and get device info
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "loop-test"
# Wait for self_disable_10 to disable itself
try:
await asyncio.wait_for(self_disable_10_disabled.wait(), timeout=10.0)
except asyncio.TimeoutError:
pytest.fail("self_disable_10 did not disable itself within 10 seconds")
# Verify it ran at least 10 times before disabling
assert len([c for c in self_disable_10_counts if c <= 10]) == 10, (
f"Expected exactly 10 loops before disable, got {[c for c in self_disable_10_counts if c <= 10]}"
)
assert self_disable_10_counts[:10] == list(range(1, 11)), (
f"Expected first 10 counts to be 1-10, got {self_disable_10_counts[:10]}"
)
# Wait for normal_component to run at least 10 times
try:
await asyncio.wait_for(normal_component_10_loops.wait(), timeout=10.0)
except asyncio.TimeoutError:
pytest.fail(
f"normal_component did not reach 10 loops within timeout, got {len(normal_component_counts)}"
)
# Wait for redundant operation tests
try:
await asyncio.wait_for(redundant_enable_tested.wait(), timeout=10.0)
except asyncio.TimeoutError:
pytest.fail("redundant_enable did not test enabling when already enabled")
try:
await asyncio.wait_for(redundant_disable_tested.wait(), timeout=10.0)
except asyncio.TimeoutError:
pytest.fail(
"redundant_disable did not test disabling when will be disabled"
)
# Wait to see if self_disable_10 gets re-enabled
try:
await asyncio.wait_for(self_disable_10_re_enabled.wait(), timeout=5.0)
except asyncio.TimeoutError:
pytest.fail("self_disable_10 was not re-enabled within 5 seconds")
# Component was re-enabled - verify it ran more times
later_self_disable_counts = [c for c in self_disable_10_counts if c > 10]
assert later_self_disable_counts, (
"self_disable_10 was re-enabled but did not run additional times"
)

View File

@@ -13,7 +13,19 @@ from aioesphomeapi import APIClient
ConfigWriter = Callable[[str, str | None], Awaitable[Path]]
CompileFunction = Callable[[Path], Awaitable[Path]]
RunFunction = Callable[[Path], Awaitable[asyncio.subprocess.Process]]
RunCompiledFunction = Callable[[str, str | None], AbstractAsyncContextManager[None]]
class RunCompiledFunction(Protocol):
"""Protocol for run_compiled function with optional line callback."""
def __call__( # noqa: E704
self,
yaml_content: str,
filename: str | None = None,
line_callback: Callable[[str], None] | None = None,
) -> AbstractAsyncContextManager[None]: ...
WaitFunction = Callable[[APIClient, float], Awaitable[bool]]