mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-31 07:03:55 +00:00 
			
		
		
		
	Add enable_loop_soon_any_context() for thread and ISR-safe loop enabling (#9127)
This commit is contained in:
		| @@ -7,9 +7,13 @@ CODEOWNERS = ["@esphome/tests"] | ||||
|  | ||||
| loop_test_component_ns = cg.esphome_ns.namespace("loop_test_component") | ||||
| LoopTestComponent = loop_test_component_ns.class_("LoopTestComponent", cg.Component) | ||||
| LoopTestISRComponent = loop_test_component_ns.class_( | ||||
|     "LoopTestISRComponent", cg.Component | ||||
| ) | ||||
|  | ||||
| CONF_DISABLE_AFTER = "disable_after" | ||||
| CONF_TEST_REDUNDANT_OPERATIONS = "test_redundant_operations" | ||||
| CONF_ISR_COMPONENTS = "isr_components" | ||||
|  | ||||
| COMPONENT_CONFIG_SCHEMA = cv.Schema( | ||||
|     { | ||||
| @@ -20,10 +24,18 @@ COMPONENT_CONFIG_SCHEMA = cv.Schema( | ||||
|     } | ||||
| ) | ||||
|  | ||||
| ISR_COMPONENT_CONFIG_SCHEMA = cv.Schema( | ||||
|     { | ||||
|         cv.GenerateID(): cv.declare_id(LoopTestISRComponent), | ||||
|         cv.Required(CONF_NAME): cv.string, | ||||
|     } | ||||
| ) | ||||
|  | ||||
| CONFIG_SCHEMA = cv.Schema( | ||||
|     { | ||||
|         cv.GenerateID(): cv.declare_id(LoopTestComponent), | ||||
|         cv.Required(CONF_COMPONENTS): cv.ensure_list(COMPONENT_CONFIG_SCHEMA), | ||||
|         cv.Optional(CONF_ISR_COMPONENTS): cv.ensure_list(ISR_COMPONENT_CONFIG_SCHEMA), | ||||
|     } | ||||
| ).extend(cv.COMPONENT_SCHEMA) | ||||
|  | ||||
| @@ -76,3 +88,9 @@ async def to_code(config): | ||||
|                 comp_config[CONF_TEST_REDUNDANT_OPERATIONS] | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|     # Create ISR test components | ||||
|     for isr_config in config.get(CONF_ISR_COMPONENTS, []): | ||||
|         var = cg.new_Pvariable(isr_config[CONF_ID]) | ||||
|         await cg.register_component(var, isr_config) | ||||
|         cg.add(var.set_name(isr_config[CONF_NAME])) | ||||
|   | ||||
| @@ -0,0 +1,80 @@ | ||||
| #include "loop_test_isr_component.h" | ||||
| #include "esphome/core/hal.h" | ||||
| #include "esphome/core/application.h" | ||||
|  | ||||
| namespace esphome { | ||||
| namespace loop_test_component { | ||||
|  | ||||
| static const char *const ISR_TAG = "loop_test_isr_component"; | ||||
|  | ||||
| void LoopTestISRComponent::setup() { | ||||
|   ESP_LOGI(ISR_TAG, "[%s] ISR component setup called", this->name_.c_str()); | ||||
|   this->last_check_time_ = millis(); | ||||
| } | ||||
|  | ||||
| void LoopTestISRComponent::loop() { | ||||
|   this->loop_count_++; | ||||
|   ESP_LOGI(ISR_TAG, "[%s] ISR component loop count: %d", this->name_.c_str(), this->loop_count_); | ||||
|  | ||||
|   // Disable after 5 loops | ||||
|   if (this->loop_count_ == 5) { | ||||
|     ESP_LOGI(ISR_TAG, "[%s] Disabling after 5 loops", this->name_.c_str()); | ||||
|     this->disable_loop(); | ||||
|     this->last_disable_time_ = millis(); | ||||
|     // Simulate ISR after disabling | ||||
|     this->set_timeout("simulate_isr_1", 50, [this]() { | ||||
|       ESP_LOGI(ISR_TAG, "[%s] Simulating ISR enable", this->name_.c_str()); | ||||
|       this->simulate_isr_enable(); | ||||
|       // Test reentrancy - call enable_loop() directly after ISR | ||||
|       // This simulates another thread calling enable_loop while processing ISR enables | ||||
|       this->set_timeout("test_reentrant", 10, [this]() { | ||||
|         ESP_LOGI(ISR_TAG, "[%s] Testing reentrancy - calling enable_loop() directly", this->name_.c_str()); | ||||
|         this->enable_loop(); | ||||
|       }); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   // If we get here after being disabled, it means ISR re-enabled us | ||||
|   if (this->loop_count_ > 5 && this->loop_count_ < 10) { | ||||
|     ESP_LOGI(ISR_TAG, "[%s] Running after ISR re-enable! ISR was called %d times", this->name_.c_str(), | ||||
|              this->isr_call_count_); | ||||
|   } | ||||
|  | ||||
|   // Disable again after 10 loops to test multiple ISR enables | ||||
|   if (this->loop_count_ == 10) { | ||||
|     ESP_LOGI(ISR_TAG, "[%s] Disabling again after 10 loops", this->name_.c_str()); | ||||
|     this->disable_loop(); | ||||
|     this->last_disable_time_ = millis(); | ||||
|  | ||||
|     // Test pure ISR enable without any main loop enable | ||||
|     this->set_timeout("simulate_isr_2", 50, [this]() { | ||||
|       ESP_LOGI(ISR_TAG, "[%s] Testing pure ISR enable (no main loop enable)", this->name_.c_str()); | ||||
|       this->simulate_isr_enable(); | ||||
|       // DO NOT call enable_loop() - test that ISR alone works | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   // Log when we're running after second ISR enable | ||||
|   if (this->loop_count_ > 10) { | ||||
|     ESP_LOGI(ISR_TAG, "[%s] Running after pure ISR re-enable! ISR was called %d times total", this->name_.c_str(), | ||||
|              this->isr_call_count_); | ||||
|   } | ||||
| } | ||||
|  | ||||
| void IRAM_ATTR LoopTestISRComponent::simulate_isr_enable() { | ||||
|   // This simulates what would happen in a real ISR | ||||
|   // In a real scenario, this would be called from an actual interrupt handler | ||||
|  | ||||
|   this->isr_call_count_++; | ||||
|  | ||||
|   // Call enable_loop_soon_any_context multiple times to test that it's safe | ||||
|   this->enable_loop_soon_any_context(); | ||||
|   this->enable_loop_soon_any_context();  // Test multiple calls | ||||
|   this->enable_loop_soon_any_context();  // Should be idempotent | ||||
|  | ||||
|   // Note: In a real ISR, we cannot use ESP_LOG* macros as they're not ISR-safe | ||||
|   // For testing, we'll track the call count and log it from the main loop | ||||
| } | ||||
|  | ||||
| }  // namespace loop_test_component | ||||
| }  // namespace esphome | ||||
| @@ -0,0 +1,32 @@ | ||||
| #pragma once | ||||
|  | ||||
| #include "esphome/core/component.h" | ||||
| #include "esphome/core/log.h" | ||||
| #include "esphome/core/hal.h" | ||||
|  | ||||
| namespace esphome { | ||||
| namespace loop_test_component { | ||||
|  | ||||
| class LoopTestISRComponent : public Component { | ||||
|  public: | ||||
|   void set_name(const std::string &name) { this->name_ = name; } | ||||
|  | ||||
|   void setup() override; | ||||
|   void loop() override; | ||||
|  | ||||
|   // Simulates an ISR calling enable_loop_soon_any_context | ||||
|   void simulate_isr_enable(); | ||||
|  | ||||
|   float get_setup_priority() const override { return setup_priority::DATA; } | ||||
|  | ||||
|  protected: | ||||
|   std::string name_; | ||||
|   int loop_count_{0}; | ||||
|   uint32_t last_disable_time_{0}; | ||||
|   uint32_t last_check_time_{0}; | ||||
|   bool isr_enable_pending_{false}; | ||||
|   int isr_call_count_{0}; | ||||
| }; | ||||
|  | ||||
| }  // namespace loop_test_component | ||||
| }  // namespace esphome | ||||
| @@ -35,6 +35,11 @@ loop_test_component: | ||||
|       test_redundant_operations: true | ||||
|       disable_after: 10 | ||||
|  | ||||
|   # ISR test component that uses enable_loop_soon_any_context | ||||
|   isr_components: | ||||
|     - id: isr_test | ||||
|       name: "isr_test" | ||||
|  | ||||
| # Interval to re-enable the self_disable_10 component after some time | ||||
| interval: | ||||
|   - interval: 0.5s | ||||
|   | ||||
| @@ -41,17 +41,25 @@ async def test_loop_disable_enable( | ||||
|     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() | ||||
|     # Events for ISR component testing | ||||
|     isr_component_disabled = asyncio.Event() | ||||
|     isr_component_re_enabled = asyncio.Event() | ||||
|     isr_component_pure_re_enabled = asyncio.Event() | ||||
|  | ||||
|     # Track loop counts for components | ||||
|     self_disable_10_counts: list[int] = [] | ||||
|     normal_component_counts: list[int] = [] | ||||
|     isr_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: | ||||
|         if ( | ||||
|             "loop_test_component" not in clean_line | ||||
|             and "loop_test_isr_component" not in clean_line | ||||
|         ): | ||||
|             return | ||||
|  | ||||
|         log_messages.append(clean_line) | ||||
| @@ -92,6 +100,18 @@ async def test_loop_disable_enable( | ||||
|         ): | ||||
|             redundant_disable_tested.set() | ||||
|  | ||||
|         # ISR component events | ||||
|         elif "[isr_test]" in clean_line: | ||||
|             if "ISR component loop count:" in clean_line: | ||||
|                 count = int(clean_line.split("ISR component loop count: ")[1]) | ||||
|                 isr_component_counts.append(count) | ||||
|             elif "Disabling after 5 loops" in clean_line: | ||||
|                 isr_component_disabled.set() | ||||
|             elif "Running after ISR re-enable!" in clean_line: | ||||
|                 isr_component_re_enabled.set() | ||||
|             elif "Running after pure ISR re-enable!" in clean_line: | ||||
|                 isr_component_pure_re_enabled.set() | ||||
|  | ||||
|     # Write, compile and run the ESPHome device with log callback | ||||
|     async with ( | ||||
|         run_compiled(yaml_config, line_callback=on_log_line), | ||||
| @@ -148,3 +168,40 @@ async def test_loop_disable_enable( | ||||
|         assert later_self_disable_counts, ( | ||||
|             "self_disable_10 was re-enabled but did not run additional times" | ||||
|         ) | ||||
|  | ||||
|         # Test ISR component functionality | ||||
|         # Wait for ISR component to disable itself after 5 loops | ||||
|         try: | ||||
|             await asyncio.wait_for(isr_component_disabled.wait(), timeout=3.0) | ||||
|         except asyncio.TimeoutError: | ||||
|             pytest.fail("ISR component did not disable itself within 3 seconds") | ||||
|  | ||||
|         # Verify it ran exactly 5 times before disabling | ||||
|         first_run_counts = [c for c in isr_component_counts if c <= 5] | ||||
|         assert len(first_run_counts) == 5, ( | ||||
|             f"Expected 5 loops before disable, got {first_run_counts}" | ||||
|         ) | ||||
|  | ||||
|         # Wait for component to be re-enabled by periodic ISR simulation and run again | ||||
|         try: | ||||
|             await asyncio.wait_for(isr_component_re_enabled.wait(), timeout=2.0) | ||||
|         except asyncio.TimeoutError: | ||||
|             pytest.fail("ISR component was not re-enabled after ISR call") | ||||
|  | ||||
|         # Verify it's running again after ISR enable | ||||
|         count_after_isr = len(isr_component_counts) | ||||
|         assert count_after_isr > 5, ( | ||||
|             f"Component didn't run after ISR enable: got {count_after_isr} counts total" | ||||
|         ) | ||||
|  | ||||
|         # Wait for pure ISR enable (no main loop enable) to work | ||||
|         try: | ||||
|             await asyncio.wait_for(isr_component_pure_re_enabled.wait(), timeout=2.0) | ||||
|         except asyncio.TimeoutError: | ||||
|             pytest.fail("ISR component was not re-enabled by pure ISR call") | ||||
|  | ||||
|         # Verify it ran after pure ISR enable | ||||
|         final_count = len(isr_component_counts) | ||||
|         assert final_count > 10, ( | ||||
|             f"Component didn't run after pure ISR enable: got {final_count} counts total" | ||||
|         ) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user