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:
@@ -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
|
||||
|
@@ -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]
|
||||
)
|
||||
)
|
@@ -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
|
@@ -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
|
48
tests/integration/fixtures/loop_disable_enable.yaml
Normal file
48
tests/integration/fixtures/loop_disable_enable.yaml
Normal 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
|
150
tests/integration/test_loop_disable_enable.py
Normal file
150
tests/integration/test_loop_disable_enable.py
Normal 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"
|
||||
)
|
@@ -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]]
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user