mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-30 22:53:59 +00: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