mirror of
https://github.com/esphome/esphome.git
synced 2026-05-10 14:09:14 +08:00
210 lines
10 KiB
Python
210 lines
10 KiB
Python
"""Integration tests for Component::status_set/clear_warning/error propagation.
|
|
|
|
Verifies that toggling STATUS_LED_WARNING / STATUS_LED_ERROR on individual
|
|
components correctly updates the app-wide bits on Application::app_state_,
|
|
AND that the status_led_light component actually responds to those bits
|
|
by writing to its output (the full chain from component.status_set_warning
|
|
→ App.app_state_ → status_led_light.loop() reading get_app_state()).
|
|
|
|
Exercises the multi-component OR semantics (the app bit stays set while
|
|
any component still has the flag, and only clears when the last component
|
|
clears its bit), the independence of warning and error, and the actual
|
|
status_led_light read of the bits via a fake template output that counts
|
|
writes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from .state_utils import InitialStateHelper, SensorTracker, build_key_to_entity_mapping
|
|
from .types import APIClientConnectedFactory, RunCompiledFunction
|
|
|
|
# Time to let the host-mode main loop run so status_led_light.loop() can
|
|
# execute enough iterations to produce measurable write-count changes on
|
|
# the fake template output. 300 ms is well above the minimum needed.
|
|
STATUS_LED_SETTLE_S = 0.3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_flags(
|
|
yaml_config: str,
|
|
run_compiled: RunCompiledFunction,
|
|
api_client_connected: APIClientConnectedFactory,
|
|
) -> None:
|
|
async with run_compiled(yaml_config), api_client_connected() as client:
|
|
entities, services = await client.list_entities_services()
|
|
|
|
# Map every custom API service by name for the test to execute.
|
|
svc = {s.name: s for s in services}
|
|
for name in (
|
|
"set_warning_a",
|
|
"clear_warning_a",
|
|
"set_warning_b",
|
|
"clear_warning_b",
|
|
"set_error_a",
|
|
"clear_error_a",
|
|
"set_error_b",
|
|
"clear_error_b",
|
|
"snapshot_led",
|
|
):
|
|
assert name in svc, f"service {name} not registered"
|
|
|
|
# Track every sensor we care about. SensorTracker gives us
|
|
# expect(value) / expect_any() futures that resolve when a
|
|
# matching state arrives; much simpler than manual bookkeeping.
|
|
tracker = SensorTracker(
|
|
[
|
|
"app_warning_bit",
|
|
"app_error_bit",
|
|
"status_led_writes",
|
|
"status_led_last_state",
|
|
]
|
|
)
|
|
tracker.key_to_sensor.update(
|
|
build_key_to_entity_mapping(entities, list(tracker.sensor_states.keys()))
|
|
)
|
|
|
|
# Swallow initial state broadcasts so the test only reacts to
|
|
# state changes triggered by our service calls.
|
|
initial_state_helper = InitialStateHelper(entities)
|
|
client.subscribe_states(initial_state_helper.on_state_wrapper(tracker.on_state))
|
|
try:
|
|
await initial_state_helper.wait_for_initial_states()
|
|
except TimeoutError:
|
|
pytest.fail("Timeout waiting for initial states")
|
|
|
|
async def call(name: str) -> None:
|
|
await client.execute_service(svc[name], {})
|
|
|
|
async def call_and_expect_bits(
|
|
service_name: str, *, warning: float, error: float
|
|
) -> None:
|
|
"""Execute a service and wait for both app bit sensors to match.
|
|
|
|
Each bit-toggling service calls component.update on both
|
|
app_warning_bit and app_error_bit, so both sensors publish.
|
|
"""
|
|
futures = tracker.expect_all(
|
|
{"app_warning_bit": warning, "app_error_bit": error}
|
|
)
|
|
await call(service_name)
|
|
await tracker.await_all(futures)
|
|
|
|
async def snapshot_led_writes() -> int:
|
|
"""Trigger a publish of the fake status_led output counter and return it."""
|
|
future = tracker.expect_any("status_led_writes")
|
|
await call("snapshot_led")
|
|
await tracker.await_change(future, "status_led_writes")
|
|
return int(tracker.sensor_states["status_led_writes"][-1])
|
|
|
|
# ---- Baseline: everything clean ----
|
|
await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0)
|
|
|
|
# ================================================================
|
|
# Part 1 — STATUS_LED_WARNING propagation to App.app_state_
|
|
# ================================================================
|
|
|
|
# Single component set/clear
|
|
await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0)
|
|
await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0)
|
|
|
|
# Multi-component OR: both set, clear A, bit stays (B still has it), clear B, gone
|
|
await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0)
|
|
await call_and_expect_bits("set_warning_b", warning=1.0, error=0.0)
|
|
await call_and_expect_bits("clear_warning_a", warning=1.0, error=0.0)
|
|
await call_and_expect_bits("clear_warning_b", warning=0.0, error=0.0)
|
|
|
|
# Opposite clear order
|
|
await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0)
|
|
await call_and_expect_bits("set_warning_b", warning=1.0, error=0.0)
|
|
await call_and_expect_bits("clear_warning_b", warning=1.0, error=0.0)
|
|
await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0)
|
|
|
|
# ================================================================
|
|
# Part 2 — STATUS_LED_ERROR propagation (same scenarios)
|
|
# ================================================================
|
|
|
|
await call_and_expect_bits("set_error_a", warning=0.0, error=1.0)
|
|
await call_and_expect_bits("clear_error_a", warning=0.0, error=0.0)
|
|
|
|
await call_and_expect_bits("set_error_a", warning=0.0, error=1.0)
|
|
await call_and_expect_bits("set_error_b", warning=0.0, error=1.0)
|
|
await call_and_expect_bits("clear_error_a", warning=0.0, error=1.0)
|
|
await call_and_expect_bits("clear_error_b", warning=0.0, error=0.0)
|
|
|
|
# ================================================================
|
|
# Part 3 — warning and error are independent
|
|
# ================================================================
|
|
|
|
await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0)
|
|
await call_and_expect_bits("set_error_b", warning=1.0, error=1.0)
|
|
await call_and_expect_bits("clear_warning_a", warning=0.0, error=1.0)
|
|
await call_and_expect_bits("clear_error_b", warning=0.0, error=0.0)
|
|
|
|
# ================================================================
|
|
# Part 4 — status_led_light actually reads App.app_state_
|
|
# ================================================================
|
|
# The fake status_led_light output increments status_led_write_count
|
|
# on every write. status_led_light::loop() writes its output on every
|
|
# iteration while an error/warning bit is set, so after holding a
|
|
# warning for ~300 ms we should see the counter move significantly.
|
|
# This is the end-to-end proof that the bits we set above actually
|
|
# reach status_led_light and drive its behavior.
|
|
|
|
count_before_warning = await snapshot_led_writes()
|
|
await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0)
|
|
# Let status_led_light's loop run long enough to toggle the pin
|
|
# several times (it reads get_app_state() every main loop iteration).
|
|
await asyncio.sleep(STATUS_LED_SETTLE_S)
|
|
count_after_warning = await snapshot_led_writes()
|
|
assert count_after_warning > count_before_warning, (
|
|
"status_led_light did not respond to STATUS_LED_WARNING being set: "
|
|
f"write count stayed at {count_before_warning} → {count_after_warning}. "
|
|
"The full chain Component::status_set_warning → App.app_state_ → "
|
|
"status_led_light::loop reading get_app_state() is broken."
|
|
)
|
|
await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0)
|
|
|
|
# Same check for ERROR
|
|
count_before_error = await snapshot_led_writes()
|
|
await call_and_expect_bits("set_error_a", warning=0.0, error=1.0)
|
|
await asyncio.sleep(STATUS_LED_SETTLE_S)
|
|
count_after_error = await snapshot_led_writes()
|
|
assert count_after_error > count_before_error, (
|
|
"status_led_light did not respond to STATUS_LED_ERROR being set: "
|
|
f"write count stayed at {count_before_error} → {count_after_error}. "
|
|
)
|
|
await call_and_expect_bits("clear_error_a", warning=0.0, error=0.0)
|
|
|
|
# ---- Set → clear → re-set round-trip ----
|
|
# After clearing, status_led_light stops writing (steady state).
|
|
# Re-setting the flag must make it resume. This guards against a
|
|
# future idle optimization (e.g. #15642) where status_led disables
|
|
# its own loop when idle: if the re-enable path were broken, the
|
|
# second set would not produce writes.
|
|
#
|
|
# Snapshot AFTER the clear to avoid counting writes that were still
|
|
# in-flight from the error-set phase.
|
|
count_after_clear = await snapshot_led_writes()
|
|
await asyncio.sleep(STATUS_LED_SETTLE_S)
|
|
count_after_idle = await snapshot_led_writes()
|
|
assert count_after_idle - count_after_clear <= 5, (
|
|
"status_led_light kept writing after warning/error was cleared: "
|
|
f"count grew from {count_after_clear} to {count_after_idle}. "
|
|
"Expected it to stop writing once all status bits were clear."
|
|
)
|
|
# Re-set warning — writes must resume.
|
|
await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0)
|
|
await asyncio.sleep(STATUS_LED_SETTLE_S)
|
|
count_after_reset = await snapshot_led_writes()
|
|
assert count_after_reset > count_after_idle + 5, (
|
|
"status_led_light did not resume writing after re-setting "
|
|
f"STATUS_LED_WARNING: count went from {count_after_idle} to "
|
|
f"{count_after_reset}. If an idle optimization disabled the "
|
|
"loop, the re-enable path may be broken."
|
|
)
|
|
await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0)
|