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

Add enable_loop_soon_any_context() for thread and ISR-safe loop enabling (#9127)

This commit is contained in:
J. Nick Koston
2025-06-19 03:30:41 +02:00
committed by GitHub
parent 2e11e66db4
commit 8ba22183b9
9 changed files with 325 additions and 20 deletions

View File

@@ -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]))

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"
)