mirror of
https://github.com/esphome/esphome.git
synced 2026-11-09 14:45:57 +00:00
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
168 lines
6.9 KiB
Python
168 lines
6.9 KiB
Python
"""Integration test verifying the off phase of an effect reaches an ON/OFF-only light.
|
|
|
|
Regression test for https://github.com/esphome/esphome/issues/17873. A strobe effect
|
|
encodes its dark phase as `brightness = 0` while keeping `state = true`, so that the
|
|
effect keeps running instead of being stopped by an explicit turn-off. On a dimmable
|
|
light that works, because the output is driven by `state * brightness`. On a binary
|
|
light the dark phase used to be dropped, so the output stayed on forever.
|
|
|
|
Effect ticks are published with `publish: false` (so Home Assistant isn't spammed with
|
|
every frame), so the effect's actual output can't be observed via API state broadcasts.
|
|
Instead, this test reads the output component's log lines, which are written on every
|
|
update regardless of the publish flag.
|
|
|
|
The output log line is emitted strictly after the API state response: `perform()`
|
|
publishes inline, but the write is deferred to the next `LightState::loop()` iteration
|
|
and then has to cross the subprocess stdout pipe. So a future is armed *before* each
|
|
command and awaited afterwards, rather than reading the last observed value.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import re
|
|
from typing import Any
|
|
|
|
from aioesphomeapi import LightState
|
|
import pytest
|
|
|
|
from .log_utils import LineWaiter
|
|
from .state_utils import require_entity, subscribe_after_initial_states
|
|
from .types import APIClientConnectedFactory, RunCompiledFunction
|
|
|
|
OUTPUT_PATTERN = re.compile(r"BINARY_OUTPUT:(YES|NO)")
|
|
|
|
|
|
async def output_on(waiter: LineWaiter, output: asyncio.Future[re.Match[str]]) -> bool:
|
|
"""Await an output write armed with ``expect`` and return whether it switched on."""
|
|
match = await waiter.wait(output, 5.0, "No output write logged")
|
|
return match.group(1) == "YES"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_light_binary_effect_off_phase(
|
|
yaml_config: str,
|
|
run_compiled: RunCompiledFunction,
|
|
api_client_connected: APIClientConnectedFactory,
|
|
) -> None:
|
|
"""A strobe effect must drive a binary light's output both on and off."""
|
|
waiter = LineWaiter()
|
|
|
|
async with (
|
|
run_compiled(yaml_config, line_callback=waiter.callback),
|
|
api_client_connected() as client,
|
|
):
|
|
entities, _ = await client.list_entities_services()
|
|
light = require_entity(entities, "test_binary_light", exact=True)
|
|
|
|
# ESPHome sends the current state of every entity right after connecting; drain
|
|
# that initial burst so it can't be mistaken for the response to a command below.
|
|
sub = await subscribe_after_initial_states(client, entities)
|
|
|
|
async def send_and_wait(**kwargs: Any) -> LightState:
|
|
"""Send a light command and return the light's next state."""
|
|
next_light = sub.waiter.next_state(light.key, LightState)
|
|
client.light_command(key=light.key, **kwargs)
|
|
return await next_light
|
|
|
|
# A plain turn-on must drive the output on -- brightness defaults to 100% and
|
|
# must not be mistaken for a dark phase.
|
|
output = waiter.expect(OUTPUT_PATTERN)
|
|
state = await send_and_wait(state=True)
|
|
assert state.state is True
|
|
assert await output_on(waiter, output) is True, (
|
|
"Plain turn-on did not switch the output on"
|
|
)
|
|
|
|
# Run the strobe effect; both phases must reach the output.
|
|
start = len(waiter.lines)
|
|
state = await send_and_wait(effect="Fast Strobe")
|
|
assert state.effect == "Fast Strobe"
|
|
# Let several effect cycles run (each phase is 50ms in the fixture).
|
|
await asyncio.sleep(1.0)
|
|
|
|
observed = [
|
|
m.group(1) == "YES"
|
|
for line in waiter.lines[start:]
|
|
if (m := OUTPUT_PATTERN.search(line))
|
|
]
|
|
assert True in observed, (
|
|
f"Strobe effect never switched the output on -- got {observed}"
|
|
)
|
|
assert False in observed, (
|
|
f"Strobe effect never switched the output off; its dark phase was lost -- "
|
|
f"got {observed}"
|
|
)
|
|
|
|
# Stopping the effect must leave the light usable.
|
|
state = await send_and_wait(effect="None")
|
|
assert state.effect == "None"
|
|
output = waiter.expect(OUTPUT_PATTERN)
|
|
state = await send_and_wait(state=True)
|
|
assert state.state is True
|
|
assert await output_on(waiter, output) is True, (
|
|
"Light stayed off after the effect stopped"
|
|
)
|
|
|
|
# An explicit turn-off still switches the output off.
|
|
output = waiter.expect(OUTPUT_PATTERN)
|
|
state = await send_and_wait(state=False)
|
|
assert state.state is False
|
|
assert await output_on(waiter, output) is False, (
|
|
"Turn-off did not switch the output off"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_light_binary_zero_brightness_is_recoverable(
|
|
yaml_config: str,
|
|
run_compiled: RunCompiledFunction,
|
|
api_client_connected: APIClientConnectedFactory,
|
|
) -> None:
|
|
"""Zero brightness on an ON/OFF light must not leave it permanently stuck off.
|
|
|
|
An ON/OFF light has no brightness capability, so `turn_on` with 0% brightness has
|
|
no representable "on but dark" state. It must switch the output off and report the
|
|
light as off, and a later plain turn-on must bring it back.
|
|
"""
|
|
waiter = LineWaiter()
|
|
|
|
async with (
|
|
run_compiled(yaml_config, line_callback=waiter.callback),
|
|
api_client_connected() as client,
|
|
):
|
|
entities, _ = await client.list_entities_services()
|
|
light = require_entity(entities, "test_binary_light", exact=True)
|
|
|
|
sub = await subscribe_after_initial_states(client, entities)
|
|
|
|
async def send_and_wait(**kwargs: Any) -> LightState:
|
|
"""Send a light command and return the light's next state."""
|
|
next_light = sub.waiter.next_state(light.key, LightState)
|
|
client.light_command(key=light.key, **kwargs)
|
|
return await next_light
|
|
|
|
output = waiter.expect(OUTPUT_PATTERN)
|
|
state = await send_and_wait(state=True)
|
|
assert state.state is True
|
|
assert await output_on(waiter, output) is True
|
|
|
|
# Turning on at 0% brightness has no representable "on but dark" state here,
|
|
# so the light must switch off and report itself as off.
|
|
output = waiter.expect(OUTPUT_PATTERN)
|
|
state = await send_and_wait(state=True, brightness=0.0)
|
|
assert await output_on(waiter, output) is False, (
|
|
"Zero brightness did not switch the output off"
|
|
)
|
|
assert state.state is False, (
|
|
"Light reported itself as on while its output was off"
|
|
)
|
|
|
|
# A plain turn-on must recover -- the stored zero brightness must not persist.
|
|
output = waiter.expect(OUTPUT_PATTERN)
|
|
state = await send_and_wait(state=True)
|
|
assert state.state is True
|
|
assert await output_on(waiter, output) is True, (
|
|
"Light was left permanently off by a zero-brightness turn-on"
|
|
)
|