[homeassistant] Add integration test for binary sensor initial state triggers (#18894)

This commit is contained in:
J. Nick Koston
2026-08-30 19:30:40 -05:00
committed by GitHub
parent fc977b7b5e
commit fbe306f00b
3 changed files with 162 additions and 0 deletions
@@ -0,0 +1,59 @@
esphome:
name: ha-bs-initial
host:
api:
logger:
level: DEBUG
binary_sensor:
# trigger_on_initial_state: true must fire on_press for the first state from HA
- platform: homeassistant
name: Initial On
entity_id: binary_sensor.initial_on
trigger_on_initial_state: true
on_press:
- logger.log: "initial_on on_press"
on_release:
- logger.log: "initial_on on_release"
# Default (false) must not fire on the first state, only on later changes
- platform: homeassistant
name: Default
entity_id: binary_sensor.default
on_press:
- logger.log: "default on_press"
on_release:
- logger.log: "default on_release"
# Real HA startup shape: 'unavailable' arrives before the first real state
- platform: homeassistant
name: Unavailable First
entity_id: binary_sensor.unavailable_first
trigger_on_initial_state: true
on_press:
- logger.log: "unavailable_first on_press"
on_release:
- logger.log: "unavailable_first on_release"
# Initial 'off' must fire on_release when trigger_on_initial_state is set
- platform: homeassistant
name: Initial Off
entity_id: binary_sensor.initial_off
trigger_on_initial_state: true
on_press:
- logger.log: "initial_off on_press"
on_release:
- logger.log: "initial_off on_release"
# Same 'unavailable' first shape without the flag; must stay quiet on the
# first real state and only fire on the later change
- platform: homeassistant
name: Default Unavailable First
entity_id: binary_sensor.default_unavail
on_press:
- logger.log: "default_unavail on_press"
on_release:
- logger.log: "default_unavail on_release"
+5
View File
@@ -28,6 +28,11 @@ class LineWaiter:
self._future.set_result(line)
self._future = None
async def wait_for_each(self, *texts: str, timeout: float = 10.0) -> None:
"""Await each text in turn; a text may match a line already received."""
for text in texts:
await self.wait_for(text, timeout=timeout)
async def wait_for(self, *needles: str, timeout: float = 10.0) -> str:
"""Return the first line, past or future, containing every needle."""
for line in self.lines:
@@ -0,0 +1,98 @@
"""Test on_press/on_release for homeassistant binary sensors on the first HA state."""
from __future__ import annotations
import asyncio
import pytest
from .log_utils import LineWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
ENTITIES = (
"binary_sensor.initial_on",
"binary_sensor.default",
"binary_sensor.unavailable_first",
"binary_sensor.initial_off",
"binary_sensor.default_unavail",
)
@pytest.mark.asyncio
async def test_api_homeassistant_binary_sensor_initial_state(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""The first state from HA fires on_press only with trigger_on_initial_state."""
loop = asyncio.get_running_loop()
waiter = LineWaiter()
subscribed: set[str] = set()
all_subscribed = loop.create_future()
def on_state_sub(entity_id: str, _attribute: str | None) -> None:
subscribed.add(entity_id)
if not all_subscribed.done() and subscribed.issuperset(ENTITIES):
all_subscribed.set_result(None)
async with (
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
client.subscribe_home_assistant_states(on_state_sub)
try:
await asyncio.wait_for(all_subscribed, timeout=5.0)
except TimeoutError:
pytest.fail(f"never subscribed: {set(ENTITIES) - subscribed}")
# First state from HA
client.send_home_assistant_state("binary_sensor.initial_on", "", "on")
client.send_home_assistant_state("binary_sensor.default", "", "on")
client.send_home_assistant_state(
"binary_sensor.unavailable_first", "", "unavailable"
)
client.send_home_assistant_state("binary_sensor.unavailable_first", "", "on")
client.send_home_assistant_state(
"binary_sensor.default_unavail", "", "unavailable"
)
client.send_home_assistant_state("binary_sensor.default_unavail", "", "on")
client.send_home_assistant_state("binary_sensor.initial_off", "", "off")
await waiter.wait_for("initial_on on_press", timeout=5.0)
await waiter.wait_for("unavailable_first on_press", timeout=5.0)
# Pin that the 'unavailable' message actually arrived and was rejected
await waiter.wait_for("Can't convert 'unavailable'", timeout=5.0)
# initial_off is the last state sent, so this wait also proves the
# earlier 'default' initial state was already processed
await waiter.wait_for("initial_off on_release", timeout=5.0)
# Both 'unavailable' senders must have been seen and rejected
assert sum("Can't convert 'unavailable'" in line for line in waiter.lines) == 2
# Guard every phase 2 needle against being satisfied by a stale
# phase 1 line, and pin that the initial states fired nothing else
for absent in (
"initial_on on_release",
"default on_press",
"default on_release",
"default_unavail on_press",
"default_unavail on_release",
"unavailable_first on_release",
"initial_off on_press",
):
assert not any(absent in line for line in waiter.lines), (
f"unexpected trigger before the second state change: {absent}"
)
# A later change fires for all of them
client.send_home_assistant_state("binary_sensor.initial_on", "", "off")
client.send_home_assistant_state("binary_sensor.default", "", "off")
client.send_home_assistant_state("binary_sensor.unavailable_first", "", "off")
client.send_home_assistant_state("binary_sensor.initial_off", "", "on")
client.send_home_assistant_state("binary_sensor.default_unavail", "", "off")
await waiter.wait_for_each(
"initial_on on_release",
"default on_release",
"default_unavail on_release",
"unavailable_first on_release",
"initial_off on_press",
timeout=5.0,
)