mirror of
https://github.com/esphome/esphome.git
synced 2026-02-08 00:31:58 +00:00
[scheduler] Eliminate heap allocations for std::string names and add uint32_t ID API (#13200)
This commit is contained in:
173
tests/integration/fixtures/scheduler_numeric_id_test.yaml
Normal file
173
tests/integration/fixtures/scheduler_numeric_id_test.yaml
Normal file
@@ -0,0 +1,173 @@
|
||||
esphome:
|
||||
name: scheduler-numeric-id-test
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- logger.log: "Starting scheduler numeric ID tests"
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
globals:
|
||||
- id: timeout_counter
|
||||
type: int
|
||||
initial_value: '0'
|
||||
- id: interval_counter
|
||||
type: int
|
||||
initial_value: '0'
|
||||
- id: retry_counter
|
||||
type: int
|
||||
initial_value: '0'
|
||||
- id: tests_done
|
||||
type: bool
|
||||
initial_value: 'false'
|
||||
- id: results_reported
|
||||
type: bool
|
||||
initial_value: 'false'
|
||||
|
||||
script:
|
||||
- id: test_numeric_ids
|
||||
then:
|
||||
- logger.log: "Testing numeric ID timeouts and intervals"
|
||||
- lambda: |-
|
||||
auto *component1 = id(test_sensor1);
|
||||
|
||||
// Test 1: Numeric ID with set_timeout (uint32_t)
|
||||
App.scheduler.set_timeout(component1, 1001U, 50, []() {
|
||||
ESP_LOGI("test", "Numeric timeout 1001 fired");
|
||||
id(timeout_counter) += 1;
|
||||
});
|
||||
|
||||
// Test 2: Another numeric ID timeout
|
||||
App.scheduler.set_timeout(component1, 1002U, 100, []() {
|
||||
ESP_LOGI("test", "Numeric timeout 1002 fired");
|
||||
id(timeout_counter) += 1;
|
||||
});
|
||||
|
||||
// Test 3: Numeric ID with set_interval
|
||||
App.scheduler.set_interval(component1, 2001U, 200, []() {
|
||||
ESP_LOGI("test", "Numeric interval 2001 fired, count: %d", id(interval_counter));
|
||||
id(interval_counter) += 1;
|
||||
if (id(interval_counter) >= 3) {
|
||||
App.scheduler.cancel_interval(id(test_sensor1), 2001U);
|
||||
ESP_LOGI("test", "Cancelled numeric interval 2001");
|
||||
}
|
||||
});
|
||||
|
||||
// Test 4: Cancel timeout with numeric ID
|
||||
App.scheduler.set_timeout(component1, 3001U, 5000, []() {
|
||||
ESP_LOGE("test", "ERROR: Timeout 3001 should have been cancelled");
|
||||
});
|
||||
App.scheduler.cancel_timeout(component1, 3001U);
|
||||
ESP_LOGI("test", "Cancelled numeric timeout 3001");
|
||||
|
||||
// Test 5: Multiple timeouts with same numeric ID - only last should execute
|
||||
for (int i = 0; i < 5; i++) {
|
||||
App.scheduler.set_timeout(component1, 4001U, 300 + i*10, [i]() {
|
||||
ESP_LOGI("test", "Duplicate numeric timeout %d fired", i);
|
||||
id(timeout_counter) += 1;
|
||||
});
|
||||
}
|
||||
ESP_LOGI("test", "Created 5 timeouts with same numeric ID 4001");
|
||||
|
||||
// Test 6: Cancel non-existent numeric ID
|
||||
bool cancelled_nonexistent = App.scheduler.cancel_timeout(component1, 9999U);
|
||||
ESP_LOGI("test", "Cancel non-existent numeric ID result: %s",
|
||||
cancelled_nonexistent ? "true (unexpected!)" : "false (expected)");
|
||||
|
||||
// Test 7: Component method uint32_t overloads
|
||||
class TestNumericComponent : public Component {
|
||||
public:
|
||||
void test_numeric_methods() {
|
||||
// Test set_timeout with uint32_t ID
|
||||
this->set_timeout(5001U, 150, []() {
|
||||
ESP_LOGI("test", "Component numeric timeout 5001 fired");
|
||||
id(timeout_counter) += 1;
|
||||
});
|
||||
|
||||
// Test set_interval with uint32_t ID
|
||||
// Capture 'this' pointer so we can cancel with correct component
|
||||
auto *self = this;
|
||||
this->set_interval(5002U, 400, [self]() {
|
||||
ESP_LOGI("test", "Component numeric interval 5002 fired");
|
||||
id(interval_counter) += 1;
|
||||
// Cancel after first fire - must use same component pointer
|
||||
App.scheduler.cancel_interval(self, 5002U);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
static TestNumericComponent test_component;
|
||||
test_component.test_numeric_methods();
|
||||
|
||||
// Test 8: Zero ID (edge case)
|
||||
App.scheduler.set_timeout(component1, 0U, 200, []() {
|
||||
ESP_LOGI("test", "Numeric timeout with ID 0 fired");
|
||||
id(timeout_counter) += 1;
|
||||
});
|
||||
|
||||
// Test 9: Max uint32_t ID (edge case)
|
||||
App.scheduler.set_timeout(component1, 0xFFFFFFFFU, 250, []() {
|
||||
ESP_LOGI("test", "Numeric timeout with max ID fired");
|
||||
id(timeout_counter) += 1;
|
||||
});
|
||||
|
||||
// Test 10: set_retry with numeric ID
|
||||
App.scheduler.set_retry(component1, 6001U, 50, 3,
|
||||
[](uint8_t retry_countdown) {
|
||||
id(retry_counter)++;
|
||||
ESP_LOGI("test", "Numeric retry 6001 attempt %d (countdown=%d)",
|
||||
id(retry_counter), retry_countdown);
|
||||
if (id(retry_counter) >= 2) {
|
||||
ESP_LOGI("test", "Numeric retry 6001 done");
|
||||
return RetryResult::DONE;
|
||||
}
|
||||
return RetryResult::RETRY;
|
||||
});
|
||||
|
||||
// Test 11: cancel_retry with numeric ID
|
||||
App.scheduler.set_retry(component1, 6002U, 100, 5,
|
||||
[](uint8_t retry_countdown) {
|
||||
ESP_LOGE("test", "ERROR: Numeric retry 6002 should have been cancelled");
|
||||
return RetryResult::RETRY;
|
||||
});
|
||||
App.scheduler.cancel_retry(component1, 6002U);
|
||||
ESP_LOGI("test", "Cancelled numeric retry 6002");
|
||||
|
||||
- id: report_results
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d, Retries: %d",
|
||||
id(timeout_counter), id(interval_counter), id(retry_counter));
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: Test Sensor 1
|
||||
id: test_sensor1
|
||||
lambda: return 1.0;
|
||||
update_interval: never
|
||||
|
||||
interval:
|
||||
# Run numeric ID tests after boot
|
||||
- interval: 0.1s
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return id(tests_done) == false;'
|
||||
then:
|
||||
- lambda: 'id(tests_done) = true;'
|
||||
- script.execute: test_numeric_ids
|
||||
- logger.log: "Started numeric ID tests"
|
||||
|
||||
# Report results after tests complete
|
||||
- interval: 0.2s
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return id(tests_done) && !id(results_reported);'
|
||||
then:
|
||||
- lambda: 'id(results_reported) = true;'
|
||||
- delay: 1.5s
|
||||
- script.execute: report_results
|
||||
@@ -43,9 +43,6 @@ globals:
|
||||
- id: static_char_retry_counter
|
||||
type: int
|
||||
initial_value: '0'
|
||||
- id: mixed_cancel_result
|
||||
type: bool
|
||||
initial_value: 'false'
|
||||
|
||||
# Using different component types for each test to ensure isolation
|
||||
sensor:
|
||||
@@ -271,23 +268,6 @@ script:
|
||||
ESP_LOGI("test", "Static cancel result: %s", result ? "true" : "false");
|
||||
});
|
||||
|
||||
# Test 10: Mix string and const char* cancel
|
||||
- logger.log: "=== Test 10: Mixed string/const char* ==="
|
||||
- lambda: |-
|
||||
auto *component = id(immediate_done_sensor);
|
||||
|
||||
// Set with std::string
|
||||
std::string str_name = "mixed_retry";
|
||||
App.scheduler.set_retry(component, str_name, 40, 3,
|
||||
[](uint8_t retry_countdown) {
|
||||
ESP_LOGI("test", "Mixed retry - should be cancelled");
|
||||
return RetryResult::RETRY;
|
||||
});
|
||||
|
||||
// Cancel with const char*
|
||||
id(mixed_cancel_result) = App.scheduler.cancel_retry(component, "mixed_retry");
|
||||
ESP_LOGI("test", "Mixed cancel result: %s", id(mixed_cancel_result) ? "true" : "false");
|
||||
|
||||
# Wait for all tests to complete before reporting
|
||||
- delay: 500ms
|
||||
|
||||
@@ -303,5 +283,4 @@ script:
|
||||
ESP_LOGI("test", "Multiple same name counter: %d (expected 20+)", id(multiple_same_name_counter));
|
||||
ESP_LOGI("test", "Const char retry counter: %d (expected 1)", id(const_char_retry_counter));
|
||||
ESP_LOGI("test", "Static char retry counter: %d (expected 1)", id(static_char_retry_counter));
|
||||
ESP_LOGI("test", "Mixed cancel result: %s (expected true)", id(mixed_cancel_result) ? "true" : "false");
|
||||
ESP_LOGI("test", "All retry tests completed");
|
||||
|
||||
217
tests/integration/test_scheduler_numeric_id_test.py
Normal file
217
tests/integration/test_scheduler_numeric_id_test.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""Test scheduler numeric ID (uint32_t) overloads."""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_numeric_id_test(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test that scheduler handles numeric IDs (uint32_t) correctly."""
|
||||
# Track counts
|
||||
timeout_count = 0
|
||||
interval_count = 0
|
||||
retry_count = 0
|
||||
|
||||
# Events for each test completion
|
||||
numeric_timeout_1001_fired = asyncio.Event()
|
||||
numeric_timeout_1002_fired = asyncio.Event()
|
||||
numeric_interval_2001_fired = asyncio.Event()
|
||||
numeric_interval_cancelled = asyncio.Event()
|
||||
numeric_timeout_cancelled = asyncio.Event()
|
||||
duplicate_timeout_fired = asyncio.Event()
|
||||
component_timeout_fired = asyncio.Event()
|
||||
component_interval_fired = asyncio.Event()
|
||||
zero_id_timeout_fired = asyncio.Event()
|
||||
max_id_timeout_fired = asyncio.Event()
|
||||
numeric_retry_done = asyncio.Event()
|
||||
numeric_retry_cancelled = asyncio.Event()
|
||||
final_results_logged = asyncio.Event()
|
||||
|
||||
# Track interval counts
|
||||
numeric_interval_count = 0
|
||||
numeric_retry_count = 0
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
nonlocal timeout_count, interval_count, retry_count
|
||||
nonlocal numeric_interval_count, numeric_retry_count
|
||||
|
||||
# Strip ANSI color codes
|
||||
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
|
||||
|
||||
# Check for numeric timeout completions
|
||||
if "Numeric timeout 1001 fired" in clean_line:
|
||||
numeric_timeout_1001_fired.set()
|
||||
timeout_count += 1
|
||||
|
||||
elif "Numeric timeout 1002 fired" in clean_line:
|
||||
numeric_timeout_1002_fired.set()
|
||||
timeout_count += 1
|
||||
|
||||
# Check for numeric interval
|
||||
elif "Numeric interval 2001 fired" in clean_line:
|
||||
match = re.search(r"count: (\d+)", clean_line)
|
||||
if match:
|
||||
numeric_interval_count = int(match.group(1))
|
||||
numeric_interval_2001_fired.set()
|
||||
|
||||
elif "Cancelled numeric interval 2001" in clean_line:
|
||||
numeric_interval_cancelled.set()
|
||||
|
||||
elif "Cancelled numeric timeout 3001" in clean_line:
|
||||
numeric_timeout_cancelled.set()
|
||||
|
||||
# Check for duplicate timeout (only last should fire)
|
||||
elif "Duplicate numeric timeout" in clean_line:
|
||||
match = re.search(r"timeout (\d+) fired", clean_line)
|
||||
if match and match.group(1) == "4":
|
||||
duplicate_timeout_fired.set()
|
||||
timeout_count += 1
|
||||
|
||||
# Check for component method tests
|
||||
elif "Component numeric timeout 5001 fired" in clean_line:
|
||||
component_timeout_fired.set()
|
||||
timeout_count += 1
|
||||
|
||||
elif "Component numeric interval 5002 fired" in clean_line:
|
||||
component_interval_fired.set()
|
||||
interval_count += 1
|
||||
|
||||
# Check for edge case tests
|
||||
elif "Numeric timeout with ID 0 fired" in clean_line:
|
||||
zero_id_timeout_fired.set()
|
||||
timeout_count += 1
|
||||
|
||||
elif "Numeric timeout with max ID fired" in clean_line:
|
||||
max_id_timeout_fired.set()
|
||||
timeout_count += 1
|
||||
|
||||
# Check for numeric retry tests
|
||||
elif "Numeric retry 6001 attempt" in clean_line:
|
||||
match = re.search(r"attempt (\d+)", clean_line)
|
||||
if match:
|
||||
numeric_retry_count = int(match.group(1))
|
||||
|
||||
elif "Numeric retry 6001 done" in clean_line:
|
||||
numeric_retry_done.set()
|
||||
|
||||
elif "Cancelled numeric retry 6002" in clean_line:
|
||||
numeric_retry_cancelled.set()
|
||||
|
||||
# Check for final results
|
||||
elif "Final results" in clean_line:
|
||||
match = re.search(
|
||||
r"Timeouts: (\d+), Intervals: (\d+), Retries: (\d+)", clean_line
|
||||
)
|
||||
if match:
|
||||
timeout_count = int(match.group(1))
|
||||
interval_count = int(match.group(2))
|
||||
retry_count = int(match.group(3))
|
||||
final_results_logged.set()
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=on_log_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
# Verify we can connect
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
assert device_info.name == "scheduler-numeric-id-test"
|
||||
|
||||
# Wait for numeric timeout tests
|
||||
try:
|
||||
await asyncio.wait_for(numeric_timeout_1001_fired.wait(), timeout=0.5)
|
||||
except TimeoutError:
|
||||
pytest.fail("Numeric timeout 1001 did not fire within 0.5 seconds")
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(numeric_timeout_1002_fired.wait(), timeout=0.5)
|
||||
except TimeoutError:
|
||||
pytest.fail("Numeric timeout 1002 did not fire within 0.5 seconds")
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(numeric_interval_2001_fired.wait(), timeout=1.0)
|
||||
except TimeoutError:
|
||||
pytest.fail("Numeric interval 2001 did not fire within 1 second")
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(numeric_interval_cancelled.wait(), timeout=2.0)
|
||||
except TimeoutError:
|
||||
pytest.fail("Numeric interval 2001 was not cancelled within 2 seconds")
|
||||
|
||||
# Verify numeric interval ran at least twice
|
||||
assert numeric_interval_count >= 2, (
|
||||
f"Expected numeric interval to run at least 2 times, got {numeric_interval_count}"
|
||||
)
|
||||
|
||||
# Verify numeric timeout was cancelled
|
||||
assert numeric_timeout_cancelled.is_set(), (
|
||||
"Numeric timeout 3001 should have been cancelled"
|
||||
)
|
||||
|
||||
# Wait for duplicate timeout (only last one should fire)
|
||||
try:
|
||||
await asyncio.wait_for(duplicate_timeout_fired.wait(), timeout=1.0)
|
||||
except TimeoutError:
|
||||
pytest.fail("Duplicate numeric timeout did not fire within 1 second")
|
||||
|
||||
# Wait for component method tests
|
||||
try:
|
||||
await asyncio.wait_for(component_timeout_fired.wait(), timeout=0.5)
|
||||
except TimeoutError:
|
||||
pytest.fail("Component numeric timeout did not fire within 0.5 seconds")
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(component_interval_fired.wait(), timeout=1.0)
|
||||
except TimeoutError:
|
||||
pytest.fail("Component numeric interval did not fire within 1 second")
|
||||
|
||||
# Wait for edge case tests
|
||||
try:
|
||||
await asyncio.wait_for(zero_id_timeout_fired.wait(), timeout=0.5)
|
||||
except TimeoutError:
|
||||
pytest.fail("Zero ID timeout did not fire within 0.5 seconds")
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(max_id_timeout_fired.wait(), timeout=0.5)
|
||||
except TimeoutError:
|
||||
pytest.fail("Max ID timeout did not fire within 0.5 seconds")
|
||||
|
||||
# Wait for numeric retry tests
|
||||
try:
|
||||
await asyncio.wait_for(numeric_retry_done.wait(), timeout=1.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
f"Numeric retry 6001 did not complete. Count: {numeric_retry_count}"
|
||||
)
|
||||
|
||||
assert numeric_retry_count >= 2, (
|
||||
f"Expected at least 2 numeric retry attempts, got {numeric_retry_count}"
|
||||
)
|
||||
|
||||
# Verify numeric retry was cancelled
|
||||
assert numeric_retry_cancelled.is_set(), (
|
||||
"Numeric retry 6002 should have been cancelled"
|
||||
)
|
||||
|
||||
# Wait for final results
|
||||
try:
|
||||
await asyncio.wait_for(final_results_logged.wait(), timeout=3.0)
|
||||
except TimeoutError:
|
||||
pytest.fail("Final results were not logged within 3 seconds")
|
||||
|
||||
# Verify results
|
||||
assert timeout_count >= 6, f"Expected at least 6 timeouts, got {timeout_count}"
|
||||
assert interval_count >= 3, (
|
||||
f"Expected at least 3 interval fires, got {interval_count}"
|
||||
)
|
||||
assert retry_count >= 2, (
|
||||
f"Expected at least 2 retry attempts, got {retry_count}"
|
||||
)
|
||||
@@ -25,7 +25,6 @@ async def test_scheduler_retry_test(
|
||||
multiple_name_done = asyncio.Event()
|
||||
const_char_done = asyncio.Event()
|
||||
static_char_done = asyncio.Event()
|
||||
mixed_cancel_done = asyncio.Event()
|
||||
test_complete = asyncio.Event()
|
||||
|
||||
# Track retry counts
|
||||
@@ -42,14 +41,13 @@ async def test_scheduler_retry_test(
|
||||
# Track specific test results
|
||||
cancel_result = None
|
||||
empty_cancel_result = None
|
||||
mixed_cancel_result = None
|
||||
backoff_intervals = []
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
nonlocal simple_retry_count, backoff_retry_count, immediate_done_count
|
||||
nonlocal cancel_retry_count, empty_name_retry_count, component_retry_count
|
||||
nonlocal multiple_name_count, const_char_retry_count, static_char_retry_count
|
||||
nonlocal cancel_result, empty_cancel_result, mixed_cancel_result
|
||||
nonlocal cancel_result, empty_cancel_result
|
||||
|
||||
# Strip ANSI color codes
|
||||
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
|
||||
@@ -129,11 +127,6 @@ async def test_scheduler_retry_test(
|
||||
# This is part of test 9, but we don't track it separately
|
||||
pass
|
||||
|
||||
# Mixed cancel test
|
||||
elif "Mixed cancel result:" in clean_line:
|
||||
mixed_cancel_result = "true" in clean_line
|
||||
mixed_cancel_done.set()
|
||||
|
||||
# Test completion
|
||||
elif "All retry tests completed" in clean_line:
|
||||
test_complete.set()
|
||||
@@ -279,16 +272,6 @@ async def test_scheduler_retry_test(
|
||||
f"Expected 1 static char retry call, got {static_char_retry_count}"
|
||||
)
|
||||
|
||||
# Wait for mixed cancel test
|
||||
try:
|
||||
await asyncio.wait_for(mixed_cancel_done.wait(), timeout=1.0)
|
||||
except TimeoutError:
|
||||
pytest.fail("Mixed cancel test did not complete")
|
||||
|
||||
assert mixed_cancel_result is True, (
|
||||
"Mixed string/const char cancel should have succeeded"
|
||||
)
|
||||
|
||||
# Wait for test completion
|
||||
try:
|
||||
await asyncio.wait_for(test_complete.wait(), timeout=1.0)
|
||||
|
||||
Reference in New Issue
Block a user