mirror of
https://github.com/esphome/esphome.git
synced 2026-10-05 10:31:29 +00:00
Merge remote-tracking branch 'origin/dev' into integration
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
esphome:
|
||||
name: test-ha-action-no-subscriber
|
||||
friendly_name: Home Assistant Action No Subscriber Test
|
||||
on_boot:
|
||||
# Fires before any client is connected - dropped with a warning.
|
||||
- homeassistant.action:
|
||||
action: test.boot_action
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
on_client_connected:
|
||||
# Fires at authentication time, before the client has subscribed to
|
||||
# Home Assistant actions - dropped with a warning.
|
||||
- homeassistant.action:
|
||||
action: test.connected_action
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: Send Action Button
|
||||
id: send_action_button
|
||||
on_press:
|
||||
# Pressed only after the client has subscribed - must be delivered.
|
||||
- homeassistant.action:
|
||||
action: test.button_action
|
||||
data:
|
||||
value: subscribed
|
||||
@@ -0,0 +1,6 @@
|
||||
esphome:
|
||||
name: zero-psk-provision-test
|
||||
host:
|
||||
api:
|
||||
encryption:
|
||||
logger:
|
||||
@@ -0,0 +1,6 @@
|
||||
esphome:
|
||||
name: zero-psk-plaintext-test
|
||||
host:
|
||||
api:
|
||||
encryption:
|
||||
logger:
|
||||
@@ -18,9 +18,6 @@ globals:
|
||||
- id: interval_counter
|
||||
type: int
|
||||
initial_value: '0'
|
||||
- id: retry_counter
|
||||
type: int
|
||||
initial_value: '0'
|
||||
- id: defer_counter
|
||||
type: int
|
||||
initial_value: '0'
|
||||
@@ -118,29 +115,7 @@ script:
|
||||
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");
|
||||
|
||||
// Test 12: defer with numeric ID (Component method)
|
||||
// Test 10: defer with numeric ID (Component method)
|
||||
class TestDeferComponent : public Component {
|
||||
public:
|
||||
void test_defer_methods() {
|
||||
@@ -161,7 +136,7 @@ script:
|
||||
static TestDeferComponent test_defer_component;
|
||||
test_defer_component.test_defer_methods();
|
||||
|
||||
// Test 13: cancel_defer with numeric ID (Component method)
|
||||
// Test 11: cancel_defer with numeric ID (Component method)
|
||||
class TestCancelDeferComponent : public Component {
|
||||
public:
|
||||
void test_cancel_defer() {
|
||||
@@ -181,8 +156,8 @@ script:
|
||||
- id: report_results
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d, Retries: %d, Defers: %d",
|
||||
id(timeout_counter), id(interval_counter), id(retry_counter), id(defer_counter));
|
||||
ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d, Defers: %d",
|
||||
id(timeout_counter), id(interval_counter), id(defer_counter));
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
esphome:
|
||||
debug_scheduler: true # Enable scheduler leak detection
|
||||
name: scheduler-retry-test
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- logger.log: "Starting scheduler retry tests"
|
||||
# Run all tests sequentially with delays
|
||||
- script.execute: run_all_tests
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERY_VERBOSE
|
||||
|
||||
globals:
|
||||
- id: simple_retry_counter
|
||||
type: int
|
||||
initial_value: '0'
|
||||
- id: backoff_retry_counter
|
||||
type: int
|
||||
initial_value: '0'
|
||||
- id: backoff_last_attempt_time
|
||||
type: uint32_t
|
||||
initial_value: '0'
|
||||
- id: immediate_done_counter
|
||||
type: int
|
||||
initial_value: '0'
|
||||
- id: cancel_retry_counter
|
||||
type: int
|
||||
initial_value: '0'
|
||||
- id: empty_name_retry_counter
|
||||
type: int
|
||||
initial_value: '0'
|
||||
- id: script_retry_counter
|
||||
type: int
|
||||
initial_value: '0'
|
||||
- id: multiple_same_name_counter
|
||||
type: int
|
||||
initial_value: '0'
|
||||
- id: const_char_retry_counter
|
||||
type: int
|
||||
initial_value: '0'
|
||||
- id: static_char_retry_counter
|
||||
type: int
|
||||
initial_value: '0'
|
||||
|
||||
# Using different component types for each test to ensure isolation
|
||||
sensor:
|
||||
- platform: template
|
||||
name: Simple Retry Test Sensor
|
||||
id: simple_retry_sensor
|
||||
lambda: return 1.0;
|
||||
update_interval: never
|
||||
|
||||
- platform: template
|
||||
name: Backoff Retry Test Sensor
|
||||
id: backoff_retry_sensor
|
||||
lambda: return 2.0;
|
||||
update_interval: never
|
||||
|
||||
- platform: template
|
||||
name: Immediate Done Test Sensor
|
||||
id: immediate_done_sensor
|
||||
lambda: return 3.0;
|
||||
update_interval: never
|
||||
|
||||
binary_sensor:
|
||||
- platform: template
|
||||
name: Cancel Retry Test Binary Sensor
|
||||
id: cancel_retry_binary_sensor
|
||||
lambda: return false;
|
||||
|
||||
- platform: template
|
||||
name: Empty Name Test Binary Sensor
|
||||
id: empty_name_binary_sensor
|
||||
lambda: return true;
|
||||
|
||||
switch:
|
||||
- platform: template
|
||||
name: Script Retry Test Switch
|
||||
id: script_retry_switch
|
||||
optimistic: true
|
||||
|
||||
- platform: template
|
||||
name: Multiple Same Name Test Switch
|
||||
id: multiple_same_name_switch
|
||||
optimistic: true
|
||||
|
||||
script:
|
||||
- id: run_all_tests
|
||||
then:
|
||||
# Test 1: Simple retry
|
||||
- logger.log: "=== Test 1: Simple retry ==="
|
||||
- lambda: |-
|
||||
auto *component = id(simple_retry_sensor);
|
||||
App.scheduler.set_retry(component, "simple_retry", 50, 3,
|
||||
[](uint8_t retry_countdown) {
|
||||
id(simple_retry_counter)++;
|
||||
ESP_LOGI("test", "Simple retry attempt %d (countdown=%d)",
|
||||
id(simple_retry_counter), retry_countdown);
|
||||
|
||||
if (id(simple_retry_counter) >= 2) {
|
||||
ESP_LOGI("test", "Simple retry succeeded on attempt %d", id(simple_retry_counter));
|
||||
return RetryResult::DONE;
|
||||
}
|
||||
return RetryResult::RETRY;
|
||||
});
|
||||
|
||||
# Test 2: Backoff retry
|
||||
- logger.log: "=== Test 2: Retry with backoff ==="
|
||||
- lambda: |-
|
||||
auto *component = id(backoff_retry_sensor);
|
||||
|
||||
App.scheduler.set_retry(component, "backoff_retry", 50, 4,
|
||||
[](uint8_t retry_countdown) {
|
||||
id(backoff_retry_counter)++;
|
||||
uint32_t now = millis();
|
||||
uint32_t interval = 0;
|
||||
|
||||
// Only calculate interval after first attempt
|
||||
if (id(backoff_retry_counter) > 1) {
|
||||
interval = now - id(backoff_last_attempt_time);
|
||||
}
|
||||
id(backoff_last_attempt_time) = now;
|
||||
|
||||
ESP_LOGI("test", "Backoff retry attempt %d (countdown=%d, interval=%dms)",
|
||||
id(backoff_retry_counter), retry_countdown, interval);
|
||||
|
||||
if (id(backoff_retry_counter) == 1) {
|
||||
ESP_LOGI("test", "First call was immediate");
|
||||
} else if (id(backoff_retry_counter) == 2) {
|
||||
ESP_LOGI("test", "Second call interval: %dms (expected ~50ms)", interval);
|
||||
} else if (id(backoff_retry_counter) == 3) {
|
||||
ESP_LOGI("test", "Third call interval: %dms (expected ~100ms)", interval);
|
||||
} else if (id(backoff_retry_counter) == 4) {
|
||||
ESP_LOGI("test", "Fourth call interval: %dms (expected ~200ms)", interval);
|
||||
ESP_LOGI("test", "Backoff retry completed");
|
||||
return RetryResult::DONE;
|
||||
}
|
||||
|
||||
return RetryResult::RETRY;
|
||||
}, 2.0f);
|
||||
|
||||
# Test 3: Immediate done
|
||||
- logger.log: "=== Test 3: Immediate done ==="
|
||||
- lambda: |-
|
||||
auto *component = id(immediate_done_sensor);
|
||||
App.scheduler.set_retry(component, "immediate_done", 50, 5,
|
||||
[](uint8_t retry_countdown) {
|
||||
id(immediate_done_counter)++;
|
||||
ESP_LOGI("test", "Immediate done retry called (countdown=%d)", retry_countdown);
|
||||
return RetryResult::DONE;
|
||||
});
|
||||
|
||||
# Test 4: Cancel retry
|
||||
- logger.log: "=== Test 4: Cancel retry ==="
|
||||
- lambda: |-
|
||||
auto *component = id(cancel_retry_binary_sensor);
|
||||
App.scheduler.set_retry(component, "cancel_test", 30, 10,
|
||||
[](uint8_t retry_countdown) {
|
||||
id(cancel_retry_counter)++;
|
||||
ESP_LOGI("test", "Cancel test retry attempt %d", id(cancel_retry_counter));
|
||||
return RetryResult::RETRY;
|
||||
});
|
||||
|
||||
// Cancel it after 100ms
|
||||
App.scheduler.set_timeout(component, "cancel_timer", 100, []() {
|
||||
bool cancelled = App.scheduler.cancel_retry(id(cancel_retry_binary_sensor), "cancel_test");
|
||||
ESP_LOGI("test", "Retry cancellation result: %s", cancelled ? "true" : "false");
|
||||
ESP_LOGI("test", "Cancel retry ran %d times before cancellation", id(cancel_retry_counter));
|
||||
});
|
||||
|
||||
# Test 5: Empty name retry
|
||||
- logger.log: "=== Test 5: Empty name retry ==="
|
||||
- lambda: |-
|
||||
auto *component = id(empty_name_binary_sensor);
|
||||
App.scheduler.set_retry(component, "", 100, 5,
|
||||
[](uint8_t retry_countdown) {
|
||||
id(empty_name_retry_counter)++;
|
||||
ESP_LOGI("test", "Empty name retry attempt %d", id(empty_name_retry_counter));
|
||||
return RetryResult::RETRY;
|
||||
});
|
||||
|
||||
// Try to cancel after 150ms
|
||||
App.scheduler.set_timeout(component, "empty_cancel_timer", 150, []() {
|
||||
bool cancelled = App.scheduler.cancel_retry(id(empty_name_binary_sensor), "");
|
||||
ESP_LOGI("test", "Empty name retry cancel result: %s",
|
||||
cancelled ? "true" : "false");
|
||||
ESP_LOGI("test", "Empty name retry ran %d times", id(empty_name_retry_counter));
|
||||
});
|
||||
|
||||
# Test 6: Component method
|
||||
- logger.log: "=== Test 6: Component::set_retry method ==="
|
||||
- lambda: |-
|
||||
class TestRetryComponent : public Component {
|
||||
public:
|
||||
void test_retry() {
|
||||
this->set_retry(50, 3,
|
||||
[](uint8_t retry_countdown) {
|
||||
id(script_retry_counter)++;
|
||||
ESP_LOGI("test", "Component retry attempt %d", id(script_retry_counter));
|
||||
if (id(script_retry_counter) >= 2) {
|
||||
return RetryResult::DONE;
|
||||
}
|
||||
return RetryResult::RETRY;
|
||||
}, 1.5f);
|
||||
}
|
||||
};
|
||||
|
||||
static TestRetryComponent test_component;
|
||||
test_component.test_retry();
|
||||
|
||||
# Test 7: Multiple same name
|
||||
- logger.log: "=== Test 7: Multiple retries with same name ==="
|
||||
- lambda: |-
|
||||
auto *component = id(multiple_same_name_switch);
|
||||
|
||||
// Set first retry
|
||||
App.scheduler.set_retry(component, "duplicate_retry", 100, 5,
|
||||
[](uint8_t retry_countdown) {
|
||||
id(multiple_same_name_counter) += 1;
|
||||
ESP_LOGI("test", "First duplicate retry - should not run");
|
||||
return RetryResult::RETRY;
|
||||
});
|
||||
|
||||
// Set second retry with same name (should cancel first)
|
||||
App.scheduler.set_retry(component, "duplicate_retry", 50, 3,
|
||||
[](uint8_t retry_countdown) {
|
||||
id(multiple_same_name_counter) += 10;
|
||||
ESP_LOGI("test", "Second duplicate retry attempt (counter=%d)",
|
||||
id(multiple_same_name_counter));
|
||||
if (id(multiple_same_name_counter) >= 20) {
|
||||
return RetryResult::DONE;
|
||||
}
|
||||
return RetryResult::RETRY;
|
||||
});
|
||||
|
||||
# Test 8: Const char* overloads
|
||||
- logger.log: "=== Test 8: Const char* overloads ==="
|
||||
- lambda: |-
|
||||
auto *component = id(simple_retry_sensor);
|
||||
|
||||
// Test 8a: Direct string literal
|
||||
App.scheduler.set_retry(component, "const_char_test", 30, 2,
|
||||
[](uint8_t retry_countdown) {
|
||||
id(const_char_retry_counter)++;
|
||||
ESP_LOGI("test", "Const char retry %d", id(const_char_retry_counter));
|
||||
return RetryResult::DONE;
|
||||
});
|
||||
|
||||
# Test 9: Static const char* variable
|
||||
- logger.log: "=== Test 9: Static const char* ==="
|
||||
- lambda: |-
|
||||
auto *component = id(backoff_retry_sensor);
|
||||
|
||||
static const char* STATIC_NAME = "static_retry_test";
|
||||
App.scheduler.set_retry(component, STATIC_NAME, 20, 1,
|
||||
[](uint8_t retry_countdown) {
|
||||
id(static_char_retry_counter)++;
|
||||
ESP_LOGI("test", "Static const char retry %d", id(static_char_retry_counter));
|
||||
return RetryResult::DONE;
|
||||
});
|
||||
|
||||
// Cancel with same static const char*
|
||||
App.scheduler.set_timeout(component, "static_cancel", 10, []() {
|
||||
static const char* STATIC_NAME = "static_retry_test";
|
||||
bool result = App.scheduler.cancel_retry(id(backoff_retry_sensor), STATIC_NAME);
|
||||
ESP_LOGI("test", "Static cancel result: %s", result ? "true" : "false");
|
||||
});
|
||||
|
||||
# Wait for all tests to complete before reporting
|
||||
- delay: 500ms
|
||||
|
||||
# Final report
|
||||
- logger.log: "=== Retry Test Results ==="
|
||||
- lambda: |-
|
||||
ESP_LOGI("test", "Simple retry counter: %d (expected 2)", id(simple_retry_counter));
|
||||
ESP_LOGI("test", "Backoff retry counter: %d (expected 4)", id(backoff_retry_counter));
|
||||
ESP_LOGI("test", "Immediate done counter: %d (expected 1)", id(immediate_done_counter));
|
||||
ESP_LOGI("test", "Cancel retry counter: %d (expected 2-4)", id(cancel_retry_counter));
|
||||
ESP_LOGI("test", "Empty name retry counter: %d (expected 1-2)", id(empty_name_retry_counter));
|
||||
ESP_LOGI("test", "Component retry counter: %d (expected 2)", id(script_retry_counter));
|
||||
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", "All retry tests completed");
|
||||
@@ -0,0 +1,86 @@
|
||||
esphome:
|
||||
name: wait-until-reentrant-restart
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
actions:
|
||||
- action: start_self_restart
|
||||
then:
|
||||
- script.execute: retry_script
|
||||
- action: start_stop_during_wait
|
||||
then:
|
||||
- globals.set:
|
||||
id: gate_open
|
||||
value: 'false'
|
||||
# num 0 is a blocker: its condition never becomes true, so it is still
|
||||
# waiting (already checked and set aside) when num 1 stops the script -
|
||||
# it must be cancelled, not restored, so its timeout must never fire
|
||||
- script.execute:
|
||||
id: waiter
|
||||
num: 0
|
||||
- script.execute:
|
||||
id: waiter
|
||||
num: 1
|
||||
- script.execute:
|
||||
id: waiter
|
||||
num: 2
|
||||
- script.execute:
|
||||
id: waiter
|
||||
num: 3
|
||||
# Give all three instances time to queue in the same wait_until
|
||||
- delay: 100ms
|
||||
- globals.set:
|
||||
id: gate_open
|
||||
value: 'true'
|
||||
- delay: 200ms
|
||||
- logger.log: "stop test complete"
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
globals:
|
||||
- id: attempt
|
||||
type: int
|
||||
initial_value: '0'
|
||||
- id: gate_open
|
||||
type: bool
|
||||
initial_value: 'false'
|
||||
|
||||
script:
|
||||
# Self-restart retry pattern: when the wait_until times out, the rest of the
|
||||
# script runs synchronously from inside the wait queue processing and restarts
|
||||
# this same script - re-entering the same WaitUntilAction while it is still
|
||||
# processing its queue. This used to corrupt the queue and crash.
|
||||
- id: retry_script
|
||||
mode: restart
|
||||
then:
|
||||
- wait_until:
|
||||
condition:
|
||||
lambda: 'return false;'
|
||||
timeout: 20ms
|
||||
- lambda: |-
|
||||
id(attempt) += 1;
|
||||
ESP_LOGD("test", "attempt %d done", id(attempt));
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return id(attempt) < 5;'
|
||||
then:
|
||||
- script.execute: retry_script
|
||||
else:
|
||||
- logger.log: "retry test complete"
|
||||
|
||||
# Parallel waiters all queued in the same wait_until; the first one to pass the
|
||||
# gate stops the script from its continuation, cancelling the other waiters
|
||||
# while the queue is still being processed.
|
||||
- id: waiter
|
||||
mode: parallel
|
||||
parameters:
|
||||
num: int
|
||||
then:
|
||||
- wait_until:
|
||||
condition:
|
||||
lambda: 'return num != 0 && id(gate_open);'
|
||||
timeout: 1s
|
||||
- lambda: 'ESP_LOGD("test", "gate passed %d", num);'
|
||||
- script.stop: waiter
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Integration test for Home Assistant actions fired without a subscriber.
|
||||
|
||||
Home Assistant subscribes to device actions shortly after authenticating, while
|
||||
on_client_connected (and similar triggers) fire right at authentication. Actions
|
||||
fired before any client has subscribed cannot be delivered - they must produce a
|
||||
warning in the log instead of vanishing silently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import ButtonInfo, HomeassistantServiceCall
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_homeassistant_action_no_subscriber(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Undeliverable actions warn in the log; actions after subscribing arrive."""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
boot_warning_future = loop.create_future()
|
||||
connected_warning_future = loop.create_future()
|
||||
button_action_future = loop.create_future()
|
||||
|
||||
def check_output(line: str) -> None:
|
||||
if (
|
||||
not boot_warning_future.done()
|
||||
and "Home Assistant action 'test.boot_action' dropped; no client connected"
|
||||
in line
|
||||
):
|
||||
boot_warning_future.set_result(True)
|
||||
if (
|
||||
not connected_warning_future.done()
|
||||
and "Home Assistant action 'test.connected_action' dropped; "
|
||||
"client has not subscribed to actions (yet)"
|
||||
in line
|
||||
):
|
||||
connected_warning_future.set_result(True)
|
||||
|
||||
service_calls: list[HomeassistantServiceCall] = []
|
||||
|
||||
def on_service_call(service_call: HomeassistantServiceCall) -> None:
|
||||
service_calls.append(service_call)
|
||||
if (
|
||||
service_call.service == "test.button_action"
|
||||
and not button_action_future.done()
|
||||
):
|
||||
button_action_future.set_result(service_call)
|
||||
|
||||
async with run_compiled(yaml_config, line_callback=check_output):
|
||||
# The on_boot action fires with no client connected at all.
|
||||
await asyncio.wait_for(boot_warning_future, timeout=10.0)
|
||||
|
||||
async with api_client_connected() as client:
|
||||
device_info = await client.device_info()
|
||||
assert device_info.name == "test-ha-action-no-subscriber"
|
||||
|
||||
# on_client_connected fired at authentication, before this client
|
||||
# subscribed to Home Assistant actions.
|
||||
await asyncio.wait_for(connected_warning_future, timeout=5.0)
|
||||
|
||||
# After subscribing, actions must be delivered normally (and the
|
||||
# dropped ones must not suddenly show up).
|
||||
client.subscribe_service_calls(on_service_call)
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
button = next(e for e in entities if isinstance(e, ButtonInfo))
|
||||
client.button_command(button.key)
|
||||
|
||||
button_call = await asyncio.wait_for(button_action_future, timeout=5.0)
|
||||
assert button_call.data == {"value": "subscribed"}
|
||||
assert [call.service for call in service_calls] == ["test.button_action"]
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Integration tests for provisioning the encryption key over a zero-PSK connection.
|
||||
|
||||
A device with `api: encryption:` but no key accepts Noise handshakes using the
|
||||
well-known all-zeros PSK. The ephemeral X25519 exchange protects the key from
|
||||
passive sniffing while it is provisioned; plaintext provisioning still works
|
||||
but is deprecated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
|
||||
from aioesphomeapi import InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
# The well-known provisioning PSK: base64 of 32 zero bytes
|
||||
ZERO_PSK = base64.b64encode(bytes(32)).decode()
|
||||
# A real key to provision
|
||||
NEW_KEY = base64.b64encode(b"n" * 32)
|
||||
# Time for the device to activate a newly saved key (100ms timer plus margin)
|
||||
KEY_ACTIVATION_DELAY = 0.5
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
||||
"""Keep host preferences per-test so every run starts unprovisioned."""
|
||||
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_zero_psk_provisioning(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Exercise the reject paths, then provision a key over the zero-PSK channel."""
|
||||
async with run_compiled(yaml_config):
|
||||
# --- Pre-provisioning reject paths (device state is unchanged) ---
|
||||
|
||||
# A wrong (non-zero) PSK fails against the zero provisioning PSK
|
||||
with pytest.raises(InvalidEncryptionKeyAPIError):
|
||||
async with api_client_connected(
|
||||
noise_psk=base64.b64encode(b"w" * 32).decode(), timeout=5
|
||||
) as client:
|
||||
await client.device_info()
|
||||
|
||||
# A plaintext client and a zero-PSK client can be connected at the
|
||||
# same time while the device is unprovisioned
|
||||
async with (
|
||||
api_client_connected() as plaintext_client,
|
||||
api_client_connected(noise_psk=ZERO_PSK) as noise_client,
|
||||
):
|
||||
plaintext_info = await plaintext_client.device_info()
|
||||
noise_info = await noise_client.device_info()
|
||||
# Both transports advertise provisioning support so old and new
|
||||
# clients can decide how to provision
|
||||
assert plaintext_info.api_encryption_provisionable is True
|
||||
assert noise_info.api_encryption_provisionable is True
|
||||
|
||||
# The all-zeros key is reserved as the provisioning PSK and is
|
||||
# rejected on both transports
|
||||
zero_key = base64.b64encode(bytes(32))
|
||||
assert await noise_client.noise_encryption_set_key(zero_key) is False
|
||||
assert await plaintext_client.noise_encryption_set_key(zero_key) is False
|
||||
|
||||
# --- Provision over the zero-PSK channel ---
|
||||
|
||||
# The unprovisioned device accepts the all-zeros PSK; the handshake's
|
||||
# ephemeral-ephemeral DH encrypts everything that follows
|
||||
async with api_client_connected(noise_psk=ZERO_PSK) as client:
|
||||
device_info = await client.device_info()
|
||||
assert device_info.name == "zero-psk-provision-test"
|
||||
assert device_info.api_encryption_supported is True
|
||||
assert device_info.api_encryption_provisionable is True
|
||||
|
||||
assert await client.noise_encryption_set_key(NEW_KEY) is True
|
||||
|
||||
# The device activates the new key shortly after responding
|
||||
await asyncio.sleep(KEY_ACTIVATION_DELAY)
|
||||
|
||||
# The new key now works, and the device is no longer provisionable
|
||||
async with api_client_connected(noise_psk=NEW_KEY.decode()) as client:
|
||||
device_info = await client.device_info()
|
||||
assert device_info.name == "zero-psk-provision-test"
|
||||
assert device_info.api_encryption_provisionable is False
|
||||
|
||||
# The zero PSK no longer works
|
||||
with pytest.raises(InvalidEncryptionKeyAPIError):
|
||||
async with api_client_connected(noise_psk=ZERO_PSK, timeout=5) as client:
|
||||
await client.device_info()
|
||||
|
||||
# Plaintext no longer works
|
||||
with pytest.raises(RequiresEncryptionAPIError):
|
||||
async with api_client_connected(timeout=5) as client:
|
||||
await client.device_info()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_zero_psk_provisioning_plaintext(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""The legacy plaintext provisioning path still works and warns."""
|
||||
log_lines: list[str] = []
|
||||
async with run_compiled(yaml_config, line_callback=log_lines.append):
|
||||
async with api_client_connected() as client:
|
||||
device_info = await client.device_info()
|
||||
assert device_info.name == "zero-psk-plaintext-test"
|
||||
|
||||
assert await client.noise_encryption_set_key(NEW_KEY) is True
|
||||
|
||||
await asyncio.sleep(KEY_ACTIVATION_DELAY)
|
||||
|
||||
# The deprecation warning was logged
|
||||
assert any("deprecated" in line for line in log_lines)
|
||||
|
||||
# The new key works; the zero PSK does not
|
||||
async with api_client_connected(noise_psk=NEW_KEY.decode()) as client:
|
||||
assert (await client.device_info()).name == "zero-psk-plaintext-test"
|
||||
|
||||
with pytest.raises(InvalidEncryptionKeyAPIError):
|
||||
async with api_client_connected(noise_psk=ZERO_PSK, timeout=5) as client:
|
||||
await client.device_info()
|
||||
@@ -322,6 +322,49 @@ async def test_light_calls(
|
||||
assert state.state is True
|
||||
assert state.brightness == pytest.approx(0.75)
|
||||
|
||||
# Test 31: Setting brightness to 0 without an explicit state implicitly turns
|
||||
# the light off; turning it back on (without an explicit brightness) then
|
||||
# restores full brightness so the light is visible again.
|
||||
client.light_command(key=rgbcw_light.key, state=True, brightness=0.5)
|
||||
state = await wait_for_state_change(rgbcw_light.key)
|
||||
assert state.state is True
|
||||
assert state.brightness == pytest.approx(0.5)
|
||||
|
||||
# Brightness 0 with no explicit state -> implicit turn-off
|
||||
client.light_command(key=rgbcw_light.key, brightness=0.0)
|
||||
state = await wait_for_state_change(rgbcw_light.key)
|
||||
assert state.state is False
|
||||
assert state.brightness == pytest.approx(0.0)
|
||||
# Turning on without an explicit brightness restores it to full brightness
|
||||
client.light_command(key=rgbcw_light.key, state=True)
|
||||
state = await wait_for_state_change(rgbcw_light.key)
|
||||
assert state.state is True
|
||||
assert state.brightness == pytest.approx(1.0)
|
||||
|
||||
# Test 31b: An explicit turn-on with brightness 0 still resets to full
|
||||
# brightness - a turn-on must never leave the light on-but-invisible. This
|
||||
# is the same path the restore logic exercises (set_state(true) +
|
||||
# set_brightness(0) from a persisted brightness=0 turn-off).
|
||||
client.light_command(key=rgbcw_light.key, state=True, brightness=0.0)
|
||||
state = await wait_for_state_change(rgbcw_light.key)
|
||||
assert state.state is True
|
||||
assert state.brightness == pytest.approx(1.0)
|
||||
|
||||
# Test 32: Turning a light on when it already has nonzero brightness leaves
|
||||
# the brightness unchanged (the reset only happens when brightness is 0).
|
||||
client.light_command(key=rgbcw_light.key, state=True, brightness=0.4)
|
||||
state = await wait_for_state_change(rgbcw_light.key)
|
||||
assert state.brightness == pytest.approx(0.4)
|
||||
|
||||
client.light_command(key=rgbcw_light.key, state=False)
|
||||
state = await wait_for_state_change(rgbcw_light.key)
|
||||
assert state.state is False
|
||||
|
||||
client.light_command(key=rgbcw_light.key, state=True)
|
||||
state = await wait_for_state_change(rgbcw_light.key)
|
||||
assert state.state is True
|
||||
assert state.brightness == pytest.approx(0.4)
|
||||
|
||||
# Final cleanup - turn all lights off
|
||||
for light in lights:
|
||||
client.light_command(
|
||||
|
||||
@@ -18,7 +18,6 @@ async def test_scheduler_numeric_id_test(
|
||||
# Track counts
|
||||
timeout_count = 0
|
||||
interval_count = 0
|
||||
retry_count = 0
|
||||
defer_count = 0
|
||||
|
||||
# Events for each test completion
|
||||
@@ -32,8 +31,6 @@ async def test_scheduler_numeric_id_test(
|
||||
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()
|
||||
numeric_defer_7001_fired = asyncio.Event()
|
||||
numeric_defer_7002_fired = asyncio.Event()
|
||||
numeric_defer_cancelled = asyncio.Event()
|
||||
@@ -41,11 +38,10 @@ async def test_scheduler_numeric_id_test(
|
||||
|
||||
# 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, defer_count
|
||||
nonlocal numeric_interval_count, numeric_retry_count
|
||||
nonlocal timeout_count, interval_count, defer_count
|
||||
nonlocal numeric_interval_count
|
||||
|
||||
# Strip ANSI color codes
|
||||
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
|
||||
@@ -97,18 +93,6 @@ async def test_scheduler_numeric_id_test(
|
||||
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 numeric defer tests
|
||||
elif "Component numeric defer 7001 fired" in clean_line:
|
||||
numeric_defer_7001_fired.set()
|
||||
@@ -122,14 +106,13 @@ async def test_scheduler_numeric_id_test(
|
||||
# Check for final results
|
||||
elif "Final results" in clean_line:
|
||||
match = re.search(
|
||||
r"Timeouts: (\d+), Intervals: (\d+), Retries: (\d+), Defers: (\d+)",
|
||||
r"Timeouts: (\d+), Intervals: (\d+), Defers: (\d+)",
|
||||
clean_line,
|
||||
)
|
||||
if match:
|
||||
timeout_count = int(match.group(1))
|
||||
interval_count = int(match.group(2))
|
||||
retry_count = int(match.group(3))
|
||||
defer_count = int(match.group(4))
|
||||
defer_count = int(match.group(3))
|
||||
final_results_logged.set()
|
||||
|
||||
async with (
|
||||
@@ -200,23 +183,6 @@ async def test_scheduler_numeric_id_test(
|
||||
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 numeric defer tests
|
||||
try:
|
||||
await asyncio.wait_for(numeric_defer_7001_fired.wait(), timeout=0.5)
|
||||
@@ -245,7 +211,4 @@ async def test_scheduler_numeric_id_test(
|
||||
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}"
|
||||
)
|
||||
assert defer_count >= 2, f"Expected at least 2 defer fires, got {defer_count}"
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
"""Test scheduler retry functionality."""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_retry_test(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test that scheduler retry functionality works correctly."""
|
||||
# Track test progress
|
||||
simple_retry_done = asyncio.Event()
|
||||
backoff_retry_done = asyncio.Event()
|
||||
immediate_done_done = asyncio.Event()
|
||||
cancel_retry_done = asyncio.Event()
|
||||
empty_name_retry_done = asyncio.Event()
|
||||
component_retry_done = asyncio.Event()
|
||||
multiple_name_done = asyncio.Event()
|
||||
const_char_done = asyncio.Event()
|
||||
static_char_done = asyncio.Event()
|
||||
test_complete = asyncio.Event()
|
||||
|
||||
# Track retry counts
|
||||
simple_retry_count = 0
|
||||
backoff_retry_count = 0
|
||||
immediate_done_count = 0
|
||||
cancel_retry_count = 0
|
||||
empty_name_retry_count = 0
|
||||
component_retry_count = 0
|
||||
multiple_name_count = 0
|
||||
const_char_retry_count = 0
|
||||
static_char_retry_count = 0
|
||||
|
||||
# Track specific test results
|
||||
cancel_result = None
|
||||
empty_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
|
||||
|
||||
# Strip ANSI color codes
|
||||
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
|
||||
|
||||
# Simple retry test
|
||||
if "Simple retry attempt" in clean_line:
|
||||
if match := re.search(r"Simple retry attempt (\d+)", clean_line):
|
||||
simple_retry_count = int(match.group(1))
|
||||
|
||||
elif "Simple retry succeeded on attempt" in clean_line:
|
||||
simple_retry_done.set()
|
||||
|
||||
# Backoff retry test
|
||||
elif "Backoff retry attempt" in clean_line:
|
||||
if match := re.search(
|
||||
r"Backoff retry attempt (\d+).*interval=(\d+)ms", clean_line
|
||||
):
|
||||
backoff_retry_count = int(match.group(1))
|
||||
interval = int(match.group(2))
|
||||
if backoff_retry_count > 1: # Skip first (immediate) call
|
||||
backoff_intervals.append(interval)
|
||||
|
||||
elif "Backoff retry completed" in clean_line:
|
||||
backoff_retry_done.set()
|
||||
|
||||
# Immediate done test
|
||||
elif "Immediate done retry called" in clean_line:
|
||||
immediate_done_count += 1
|
||||
immediate_done_done.set()
|
||||
|
||||
# Cancel retry test
|
||||
elif "Cancel test retry attempt" in clean_line:
|
||||
cancel_retry_count += 1
|
||||
|
||||
elif "Retry cancellation result:" in clean_line:
|
||||
cancel_result = "true" in clean_line
|
||||
cancel_retry_done.set()
|
||||
|
||||
# Empty name retry test
|
||||
elif "Empty name retry attempt" in clean_line:
|
||||
if match := re.search(r"Empty name retry attempt (\d+)", clean_line):
|
||||
empty_name_retry_count = int(match.group(1))
|
||||
|
||||
elif "Empty name retry cancel result:" in clean_line:
|
||||
empty_cancel_result = "true" in clean_line
|
||||
|
||||
elif "Empty name retry ran" in clean_line:
|
||||
empty_name_retry_done.set()
|
||||
|
||||
# Component retry test
|
||||
elif "Component retry attempt" in clean_line:
|
||||
if match := re.search(r"Component retry attempt (\d+)", clean_line):
|
||||
component_retry_count = int(match.group(1))
|
||||
if component_retry_count >= 2:
|
||||
component_retry_done.set()
|
||||
|
||||
# Multiple same name test
|
||||
elif "Second duplicate retry attempt" in clean_line:
|
||||
if match := re.search(r"counter=(\d+)", clean_line):
|
||||
multiple_name_count = int(match.group(1))
|
||||
if multiple_name_count >= 20:
|
||||
multiple_name_done.set()
|
||||
|
||||
# Const char retry test
|
||||
elif "Const char retry" in clean_line:
|
||||
if match := re.search(r"Const char retry (\d+)", clean_line):
|
||||
const_char_retry_count = int(match.group(1))
|
||||
const_char_done.set()
|
||||
|
||||
# Static const char retry test
|
||||
elif "Static const char retry" in clean_line:
|
||||
if match := re.search(r"Static const char retry (\d+)", clean_line):
|
||||
static_char_retry_count = int(match.group(1))
|
||||
static_char_done.set()
|
||||
|
||||
elif "Static cancel result:" in clean_line:
|
||||
# This is part of test 9, but we don't track it separately
|
||||
pass
|
||||
|
||||
# Test completion
|
||||
elif "All retry tests completed" in clean_line:
|
||||
test_complete.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-retry-test"
|
||||
|
||||
# Wait for simple retry test
|
||||
try:
|
||||
await asyncio.wait_for(simple_retry_done.wait(), timeout=1.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
f"Simple retry test did not complete. Count: {simple_retry_count}"
|
||||
)
|
||||
|
||||
assert simple_retry_count == 2, (
|
||||
f"Expected 2 simple retry attempts, got {simple_retry_count}"
|
||||
)
|
||||
|
||||
# Wait for backoff retry test
|
||||
try:
|
||||
await asyncio.wait_for(backoff_retry_done.wait(), timeout=3.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
f"Backoff retry test did not complete. Count: {backoff_retry_count}"
|
||||
)
|
||||
|
||||
assert backoff_retry_count == 4, (
|
||||
f"Expected 4 backoff retry attempts, got {backoff_retry_count}"
|
||||
)
|
||||
|
||||
# Verify backoff intervals (allowing for timing variations)
|
||||
assert len(backoff_intervals) >= 2, (
|
||||
f"Expected at least 2 intervals, got {len(backoff_intervals)}"
|
||||
)
|
||||
if len(backoff_intervals) >= 3:
|
||||
# First interval should be ~50ms (very wide tolerance for heavy system load)
|
||||
assert 20 <= backoff_intervals[0] <= 150, (
|
||||
f"First interval {backoff_intervals[0]}ms not ~50ms"
|
||||
)
|
||||
# Second interval should be ~100ms (50ms * 2.0)
|
||||
assert 50 <= backoff_intervals[1] <= 250, (
|
||||
f"Second interval {backoff_intervals[1]}ms not ~100ms"
|
||||
)
|
||||
# Third interval should be ~200ms (100ms * 2.0)
|
||||
assert 100 <= backoff_intervals[2] <= 500, (
|
||||
f"Third interval {backoff_intervals[2]}ms not ~200ms"
|
||||
)
|
||||
|
||||
# Wait for immediate done test
|
||||
try:
|
||||
await asyncio.wait_for(immediate_done_done.wait(), timeout=3.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
f"Immediate done test did not complete. Count: {immediate_done_count}"
|
||||
)
|
||||
|
||||
assert immediate_done_count == 1, (
|
||||
f"Expected 1 immediate done call, got {immediate_done_count}"
|
||||
)
|
||||
|
||||
# Wait for cancel retry test
|
||||
try:
|
||||
await asyncio.wait_for(cancel_retry_done.wait(), timeout=3.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
f"Cancel retry test did not complete. Count: {cancel_retry_count}"
|
||||
)
|
||||
|
||||
assert cancel_result is True, "Retry cancellation should have succeeded"
|
||||
assert 2 <= cancel_retry_count <= 5, (
|
||||
f"Expected 2-5 cancel retry attempts before cancellation, got {cancel_retry_count}"
|
||||
)
|
||||
|
||||
# Wait for empty name retry test
|
||||
try:
|
||||
await asyncio.wait_for(empty_name_retry_done.wait(), timeout=1.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
f"Empty name retry test did not complete. Count: {empty_name_retry_count}"
|
||||
)
|
||||
|
||||
# Empty name retry should run at least once before being cancelled
|
||||
assert 1 <= empty_name_retry_count <= 3, (
|
||||
f"Expected 1-3 empty name retry attempts, got {empty_name_retry_count}"
|
||||
)
|
||||
assert empty_cancel_result is True, (
|
||||
"Empty name retry cancel should have succeeded"
|
||||
)
|
||||
|
||||
# Wait for component retry test
|
||||
try:
|
||||
await asyncio.wait_for(component_retry_done.wait(), timeout=1.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
f"Component retry test did not complete. Count: {component_retry_count}"
|
||||
)
|
||||
|
||||
assert component_retry_count >= 2, (
|
||||
f"Expected at least 2 component retry attempts, got {component_retry_count}"
|
||||
)
|
||||
|
||||
# Wait for multiple same name test
|
||||
try:
|
||||
await asyncio.wait_for(multiple_name_done.wait(), timeout=1.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
f"Multiple same name test did not complete. Count: {multiple_name_count}"
|
||||
)
|
||||
|
||||
# Should be 20+ (only second retry should run)
|
||||
assert multiple_name_count >= 20, (
|
||||
f"Expected multiple name count >= 20 (second retry only), got {multiple_name_count}"
|
||||
)
|
||||
|
||||
# Wait for const char retry test
|
||||
try:
|
||||
await asyncio.wait_for(const_char_done.wait(), timeout=1.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
f"Const char retry test did not complete. Count: {const_char_retry_count}"
|
||||
)
|
||||
|
||||
assert const_char_retry_count == 1, (
|
||||
f"Expected 1 const char retry call, got {const_char_retry_count}"
|
||||
)
|
||||
|
||||
# Wait for static char retry test
|
||||
try:
|
||||
await asyncio.wait_for(static_char_done.wait(), timeout=1.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
f"Static char retry test did not complete. Count: {static_char_retry_count}"
|
||||
)
|
||||
|
||||
assert static_char_retry_count == 1, (
|
||||
f"Expected 1 static char retry call, got {static_char_retry_count}"
|
||||
)
|
||||
|
||||
# Wait for test completion
|
||||
try:
|
||||
await asyncio.wait_for(test_complete.wait(), timeout=1.0)
|
||||
except TimeoutError:
|
||||
pytest.fail("Test did not complete within timeout")
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Integration test for wait_until queue reentrancy.
|
||||
|
||||
When a wait_until completes, the rest of the action chain runs synchronously
|
||||
from inside the wait queue processing. That chain can re-enter the very same
|
||||
WaitUntilAction - for example a script with mode: restart that executes itself
|
||||
as a retry pattern, or a waiter that stops its own script. Both used to mutate
|
||||
the std::list while it was being iterated, corrupting it and crashing the
|
||||
device (Guru Meditation StoreProhibited in _M_transfer).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_until_reentrant_restart(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test that re-entering a wait_until from its own continuation is safe."""
|
||||
retry_complete = asyncio.Event()
|
||||
stop_complete = asyncio.Event()
|
||||
|
||||
attempt_pattern = re.compile(r"attempt (\d+) done")
|
||||
gate_pattern = re.compile(r"gate passed (\d+)")
|
||||
|
||||
attempts: list[int] = []
|
||||
gate_passed: list[int] = []
|
||||
|
||||
def check_output(line: str) -> None:
|
||||
"""Check log output for expected messages."""
|
||||
if mo := attempt_pattern.search(line):
|
||||
attempts.append(int(mo.group(1)))
|
||||
elif mo := gate_pattern.search(line):
|
||||
gate_passed.append(int(mo.group(1)))
|
||||
elif "retry test complete" in line:
|
||||
retry_complete.set()
|
||||
elif "stop test complete" in line:
|
||||
stop_complete.set()
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
assert device_info.name == "wait-until-reentrant-restart"
|
||||
|
||||
_, services = await client.list_entities_services()
|
||||
self_restart_service = next(
|
||||
(s for s in services if s.name == "start_self_restart"), None
|
||||
)
|
||||
assert self_restart_service is not None, "start_self_restart not found"
|
||||
stop_service = next(
|
||||
(s for s in services if s.name == "start_stop_during_wait"), None
|
||||
)
|
||||
assert stop_service is not None, "start_stop_during_wait not found"
|
||||
|
||||
# Scenario 1: the wait_until timeout continuation restarts its own
|
||||
# script five times, re-entering the same wait_until each time.
|
||||
await client.execute_service(self_restart_service, {})
|
||||
try:
|
||||
await asyncio.wait_for(retry_complete.wait(), timeout=10.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(f"Self-restart retry did not finish. Attempts: {attempts}")
|
||||
assert attempts == [1, 2, 3, 4, 5], attempts
|
||||
|
||||
# Scenario 2: the first waiter through the gate stops the script while
|
||||
# the other waiters are still queued in the same wait_until; both the
|
||||
# not-yet-checked waiters (2, 3) and the already-checked still-waiting
|
||||
# blocker (0) must be cancelled, not fired.
|
||||
await client.execute_service(stop_service, {})
|
||||
try:
|
||||
await asyncio.wait_for(stop_complete.wait(), timeout=10.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(f"Stop-during-wait did not finish. Gate passed: {gate_passed}")
|
||||
assert gate_passed == [1], gate_passed
|
||||
|
||||
# If the cancelled blocker had been kept, its 1s wait_until timeout
|
||||
# would still fire - give it the chance and check it stays silent.
|
||||
await asyncio.sleep(1.5)
|
||||
assert gate_passed == [1], gate_passed
|
||||
Reference in New Issue
Block a user