Compare commits

..

7 Commits

Author SHA1 Message Date
Jan Bouwhuis 046298f2ca No need for a local import of the paho mqtt client (#169925) 2026-05-06 22:45:36 +02:00
Jan Bouwhuis c92128b282 Remove advanced setting dependency for IMAP integration (#169827) 2026-05-06 22:37:27 +02:00
Christian Lackas 886e66e7e3 Bump homematicip to 2.10.0 (#169950) 2026-05-06 22:20:16 +02:00
Erik Montnemery 7da49570b5 Add support for options to todo triggers (#169947) 2026-05-06 22:16:55 +02:00
G Johansson b8baa3271b Bump holidays to 0.96 (#169939) 2026-05-06 22:08:38 +02:00
Erik Montnemery 65bc4bf1d0 Add missing trigger and condition tests (#169945) 2026-05-06 21:53:40 +02:00
Erik Montnemery 27a8d185c9 Add StatelessEntityTriggerBase base class (#169937) 2026-05-06 21:43:29 +02:00
44 changed files with 422 additions and 210 deletions
+3 -23
View File
@@ -1,36 +1,16 @@
"""Provides triggers for buttons."""
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
from . import DOMAIN
class ButtonPressedTrigger(EntityTriggerBase):
class ButtonPressedTrigger(StatelessEntityTriggerBase):
"""Trigger for button entity presses."""
_domain_specs = {DOMAIN: DomainSpec()}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the button is pressed
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the new state is not invalid."""
return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
TRIGGERS: dict[str, type[Trigger]] = {
+4 -21
View File
@@ -6,39 +6,22 @@ from homeassistant.components.event import (
DoorbellEventType,
EventDeviceClass,
)
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
class DoorbellRangTrigger(EntityTriggerBase):
class DoorbellRangTrigger(StatelessEntityTriggerBase):
"""Trigger for doorbell event entity when a ring event is received."""
_domain_specs = {EVENT_DOMAIN: DomainSpec(device_class=EventDeviceClass.DOORBELL)}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_state(self, state: State) -> bool:
"""Check if the entity is available and the event type is ring."""
return (
state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
return super().is_valid_state(state) and (
state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
)
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the event is received
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
TRIGGERS: dict[str, type[Trigger]] = {
"rang": DoorbellRangTrigger,
+6 -17
View File
@@ -2,13 +2,13 @@
import voluptuous as vol
from homeassistant.const import CONF_OPTIONS, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.const import CONF_OPTIONS
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
StatelessEntityTriggerBase,
Trigger,
TriggerConfig,
)
@@ -28,7 +28,7 @@ EVENT_RECEIVED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
)
class EventReceivedTrigger(EntityTriggerBase):
class EventReceivedTrigger(StatelessEntityTriggerBase):
"""Trigger for event entity when it receives a matching event."""
_domain_specs = {DOMAIN: DomainSpec()}
@@ -39,21 +39,10 @@ class EventReceivedTrigger(EntityTriggerBase):
super().__init__(hass, config)
self._event_types = set(self._options[CONF_EVENT_TYPE])
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the event is received
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the event type is valid and matches one of the configured types."""
return (
state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and state.attributes.get(ATTR_EVENT_TYPE) in self._event_types
"""Check if the event type matches one of the configured types."""
return super().is_valid_state(state) and (
state.attributes.get(ATTR_EVENT_TYPE) in self._event_types
)
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.95", "babel==2.15.0"]
"requirements": ["holidays==0.96", "babel==2.15.0"]
}
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.9.0"]
"requirements": ["homematicip==2.10.0"]
}
+10 -19
View File
@@ -76,14 +76,12 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): str,
# The default for new entries is to not include text and headers
vol.Optional(CONF_EVENT_MESSAGE_DATA, default=[]): EVENT_MESSAGE_DATA_SELECTOR,
vol.Optional(
CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT
): CIPHER_SELECTOR,
vol.Optional(CONF_VERIFY_SSL, default=True): BOOLEAN_SELECTOR,
}
)
CONFIG_SCHEMA_ADVANCED = {
vol.Optional(
CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT
): CIPHER_SELECTOR,
vol.Optional(CONF_VERIFY_SSL, default=True): BOOLEAN_SELECTOR,
}
OPTIONS_SCHEMA = vol.Schema(
{
@@ -93,18 +91,15 @@ OPTIONS_SCHEMA = vol.Schema(
vol.Optional(
CONF_EVENT_MESSAGE_DATA, default=MESSAGE_DATA_OPTIONS
): EVENT_MESSAGE_DATA_SELECTOR,
vol.Optional(CONF_CUSTOM_EVENT_DATA_TEMPLATE): TEMPLATE_SELECTOR,
vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All(
cv.positive_int,
vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT),
),
vol.Optional(CONF_ENABLE_PUSH, default=True): BOOLEAN_SELECTOR,
}
)
OPTIONS_SCHEMA_ADVANCED = {
vol.Optional(CONF_CUSTOM_EVENT_DATA_TEMPLATE): TEMPLATE_SELECTOR,
vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All(
cv.positive_int,
vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT),
),
vol.Optional(CONF_ENABLE_PUSH, default=True): BOOLEAN_SELECTOR,
}
async def validate_input(
hass: HomeAssistant, user_input: dict[str, Any]
@@ -151,8 +146,6 @@ class IMAPConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the initial step."""
schema = CONFIG_SCHEMA
if self.show_advanced_options:
schema = schema.extend(CONFIG_SCHEMA_ADVANCED)
if user_input is None:
return self.async_show_form(step_id="user", data_schema=schema)
@@ -250,8 +243,6 @@ class ImapOptionsFlow(OptionsFlow):
return self.async_create_entry(data={})
schema = OPTIONS_SCHEMA
if self.show_advanced_options:
schema = schema.extend(OPTIONS_SCHEMA_ADVANCED)
schema = self.add_suggested_values_to_schema(schema, entry_data)
return self.async_show_form(step_id="init", data_schema=schema, errors=errors)
+10 -28
View File
@@ -16,6 +16,8 @@ from typing import TYPE_CHECKING, Any
from uuid import uuid4
import certifi
import paho.mqtt.client as mqtt
from paho.mqtt.matcher import MQTTMatcher
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -47,6 +49,7 @@ from homeassistant.setup import SetupPhases, async_pause_setup
from homeassistant.util.collection import chunked_or_all
from homeassistant.util.logging import catch_log_exception, log_exception
from .async_client import AsyncMQTTClient
from .const import (
CONF_BIRTH_MESSAGE,
CONF_BROKER,
@@ -86,13 +89,6 @@ from .models import (
)
from .util import EnsureJobAfterCooldown, get_file_path, mqtt_config_entry_enabled
if TYPE_CHECKING:
# Only import for paho-mqtt type checking here, imports are done locally
# because integrations should be able to optionally rely on MQTT.
import paho.mqtt.client as mqtt
from .async_client import AsyncMQTTClient
_LOGGER = logging.getLogger(__name__)
MIN_BUFFER_SIZE = 131072 # Minimum buffer size to use if preferred size fails
@@ -128,8 +124,8 @@ def publish(
hass: HomeAssistant,
topic: str,
payload: PublishPayloadType,
qos: int = 0,
retain: bool = False,
qos: int | None = 0,
retain: bool | None = False,
encoding: str | None = DEFAULT_ENCODING,
) -> None:
"""Publish message to a MQTT topic."""
@@ -140,8 +136,8 @@ async def async_publish(
hass: HomeAssistant,
topic: str,
payload: PublishPayloadType,
qos: int = 0,
retain: bool = False,
qos: int | None = 0,
retain: bool | None = False,
encoding: str | None = DEFAULT_ENCODING,
) -> None:
"""Publish message to a MQTT topic."""
@@ -181,7 +177,9 @@ async def async_publish(
)
return
await mqtt_data.client.async_publish(topic, outgoing_payload, qos, retain)
await mqtt_data.client.async_publish(
topic, outgoing_payload, qos or 0, retain or False
)
@callback
@@ -321,12 +319,6 @@ class MqttClientSetup:
The setup of the MQTT client should be run in an executor job,
because it accesses files, so it does IO.
"""
# We don't import on the top because some integrations
# should be able to optionally rely on MQTT.
from paho.mqtt import client as mqtt # noqa: PLC0415
from .async_client import AsyncMQTTClient # noqa: PLC0415
config = self._config
clean_session: bool | None = None
# If no protocol setting is set in the config entry data
@@ -559,7 +551,6 @@ class MQTT:
"""Start the misc periodic."""
assert self._misc_timer is None, "Misc periodic already started"
_LOGGER.debug("%s: Starting client misc loop", self.config_entry.title)
import paho.mqtt.client as mqtt # noqa: PLC0415
# Inner function to avoid having to check late import
# each time the function is called.
@@ -703,7 +694,6 @@ class MQTT:
async def async_connect(self, client_available: asyncio.Future[bool]) -> None:
"""Connect to the host. Does not process messages yet."""
import paho.mqtt.client as mqtt # noqa: PLC0415
result: int | None = None
self._available_future = client_available
@@ -761,7 +751,6 @@ class MQTT:
async def _reconnect_loop(self) -> None:
"""Reconnect to the MQTT server."""
import paho.mqtt.client as mqtt # noqa: PLC0415
while True:
if not self.connected:
@@ -1263,9 +1252,6 @@ class MQTT:
@callback
def _async_handle_callback_exception(self, status: mqtt.MQTTErrorCode) -> None:
"""Handle a callback exception."""
# We don't import on the top because some integrations
# should be able to optionally rely on MQTT.
import paho.mqtt.client as mqtt # noqa: PLC0415
_LOGGER.warning(
"Error returned from MQTT server: %s",
@@ -1310,8 +1296,6 @@ class MQTT:
) -> None:
"""Wait for ACK from broker or raise on error."""
if result_code != 0:
import paho.mqtt.client as mqtt # noqa: PLC0415
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="mqtt_broker_error",
@@ -1358,8 +1342,6 @@ class MQTT:
def _matcher_for_topic(subscription: str) -> Callable[[str], bool]:
from paho.mqtt.matcher import MQTTMatcher # noqa: PLC0415
matcher = MQTTMatcher() # type: ignore[no-untyped-call]
matcher[subscription] = True
+1 -4
View File
@@ -22,6 +22,7 @@ from cryptography.hazmat.primitives.serialization import (
load_pem_private_key,
)
from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
import paho.mqtt.client as mqtt
import voluptuous as vol
import yaml
@@ -5479,10 +5480,6 @@ def try_connection(
user_input: dict[str, Any],
) -> bool:
"""Test if we can connect to an MQTT broker."""
# We don't import on the top because some integrations
# should be able to optionally rely on MQTT.
import paho.mqtt.client as mqtt # noqa: PLC0415
mqtt_client_setup = MqttClientSetup(user_input)
mqtt_client_setup.setup()
client = mqtt_client_setup.client
+2 -2
View File
@@ -9,6 +9,8 @@ from enum import StrEnum
import logging
from typing import TYPE_CHECKING, Any, TypedDict
from paho.mqtt.client import MQTTMessage
from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME, Platform
from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.exceptions import ServiceValidationError, TemplateError
@@ -24,8 +26,6 @@ from homeassistant.helpers.typing import (
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from paho.mqtt.client import MQTTMessage
from .client import MQTT, Subscription
from .debug_info import TimestampedPublishMessage
from .device_trigger import Trigger
+3 -23
View File
@@ -1,36 +1,16 @@
"""Provides triggers for scenes."""
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
from . import DOMAIN
class SceneActivatedTrigger(EntityTriggerBase):
class SceneActivatedTrigger(StatelessEntityTriggerBase):
"""Trigger for scene entity activations."""
_domain_specs = {DOMAIN: DomainSpec()}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the scene is activated
# it would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the new state is not invalid."""
return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
TRIGGERS: dict[str, type[Trigger]] = {
+2 -2
View File
@@ -42,8 +42,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def _publish(
topic: str,
payload: mqtt.PublishPayloadType,
qos: int,
retain: bool,
qos: int | None,
retain: bool | None,
) -> None:
await mqtt.async_publish(hass, topic, payload, qos, retain)
+2 -1
View File
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID, CONF_TARGET
from homeassistant.const import ATTR_ENTITY_ID, CONF_OPTIONS, CONF_TARGET
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
@@ -25,6 +25,7 @@ from .const import DATA_COMPONENT, DOMAIN, TodoItemStatus
ITEM_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
vol.Required(CONF_OPTIONS, default={}): {},
}
)
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
"requirements": ["holidays==0.95"]
"requirements": ["holidays==0.96"]
}
+24
View File
@@ -626,6 +626,30 @@ class EntityOriginStateTriggerBase(EntityTriggerBase):
)
class StatelessEntityTriggerBase(EntityTriggerBase):
"""Trigger for entities that don't carry meaningful state.
Used for stateless entities (buttons, scenes, doorbells, events)
whose `state.state` is just a timestamp of the last activation.
"""
_schema: vol.Schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is available and the state has changed.
STATE_UNKNOWN is allowed as the origin state so the first
activation fires.
"""
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check that the entity has been activated at least once."""
return state.state not in self._excluded_states
NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
{
vol.Required(CONF_OPTIONS, default={}): vol.All(
+2 -2
View File
@@ -1245,7 +1245,7 @@ hole==0.9.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.95
holidays==0.96
# homeassistant.components.frontend
home-assistant-frontend==20260429.3
@@ -1260,7 +1260,7 @@ homekit-audio-proxy==1.2.1
homelink-integration-api==0.0.1
# homeassistant.components.homematicip_cloud
homematicip==2.9.0
homematicip==2.10.0
# homeassistant.components.homevolt
homevolt==0.5.0
+2 -2
View File
@@ -1109,7 +1109,7 @@ hole==0.9.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.95
holidays==0.96
# homeassistant.components.frontend
home-assistant-frontend==20260429.3
@@ -1124,7 +1124,7 @@ homekit-audio-proxy==1.2.1
homelink-integration-api==0.0.1
# homeassistant.components.homematicip_cloud
homematicip==2.9.0
homematicip==2.10.0
# homeassistant.components.homevolt
homevolt==0.5.0
+1 -5
View File
@@ -29,6 +29,7 @@ from unittest.mock import AsyncMock, Mock, patch
from aiohttp.test_utils import unused_port as get_test_instance_port
from annotatedyaml import load_yaml_dict, loader as yaml_loader
import attr
from paho.mqtt.client import MQTTMessage
import pytest
from syrupy.assertion import SnapshotAssertion
import voluptuous as vol
@@ -453,11 +454,6 @@ def async_fire_mqtt_message(
retain: bool = False,
) -> None:
"""Fire the MQTT message."""
# Local import to avoid processing MQTT modules when running a testcase
# which does not use MQTT.
from paho.mqtt.client import MQTTMessage # noqa: PLC0415
from homeassistant.components.mqtt import MqttData # noqa: PLC0415
if isinstance(payload, str):
@@ -63,6 +63,9 @@ async def test_battery_conditions_gated_by_labs_flag(
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
_LEVEL_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}}
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_key", "base_options", "supports_behavior", "supports_duration"),
@@ -71,6 +74,7 @@ async def test_battery_conditions_gated_by_labs_flag(
("battery.is_not_low", {}, True, True),
("battery.is_charging", {}, True, True),
("battery.is_not_charging", {}, True, True),
("battery.is_level", _LEVEL_THRESHOLD, True, True),
],
)
async def test_battery_condition_options_validation(
+6
View File
@@ -63,6 +63,10 @@ async def test_battery_triggers_gated_by_labs_flag(
await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key)
_LEVEL_CHANGED_THRESHOLD = {"threshold": {"type": "any"}}
_LEVEL_CROSSED_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}}
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_key", "base_options", "supports_behavior", "supports_duration"),
@@ -71,6 +75,8 @@ async def test_battery_triggers_gated_by_labs_flag(
("battery.not_low", {}, True, True),
("battery.started_charging", {}, True, True),
("battery.stopped_charging", {}, True, True),
("battery.level_changed", _LEVEL_CHANGED_THRESHOLD, False, False),
("battery.level_crossed_threshold", _LEVEL_CROSSED_THRESHOLD, True, True),
],
)
async def test_battery_trigger_options_validation(
+25
View File
@@ -56,10 +56,35 @@ from tests.common import (
async_mock_service,
mock_device_registry,
)
from tests.components.common import assert_trigger_options_supported
_LOGGER = logging.getLogger(__name__)
@pytest.mark.parametrize(
("trigger_key", "base_options", "supports_behavior", "supports_duration"),
[
("calendar.event_started", {}, False, False),
("calendar.event_ended", {}, False, False),
],
)
async def test_calendar_trigger_options_validation(
hass: HomeAssistant,
trigger_key: str,
base_options: dict[str, Any] | None,
supports_behavior: bool,
supports_duration: bool,
) -> None:
"""Test that calendar triggers support the expected options."""
await assert_trigger_options_supported(
hass,
trigger_key,
base_options,
supports_behavior=supports_behavior,
supports_duration=supports_duration,
)
@dataclass
class TriggerFormat:
"""Abstraction for different trigger configuration formats."""
@@ -60,6 +60,15 @@ async def test_climate_conditions_gated_by_labs_flag(
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
_HUMIDITY_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}}
_TEMPERATURE_THRESHOLD = {
"threshold": {
"type": "above",
"value": {"number": 20, "unit_of_measurement": UnitOfTemperature.CELSIUS},
}
}
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_key", "base_options", "supports_behavior", "supports_duration"),
@@ -69,6 +78,9 @@ async def test_climate_conditions_gated_by_labs_flag(
("climate.is_cooling", {}, True, True),
("climate.is_drying", {}, True, True),
("climate.is_heating", {}, True, True),
("climate.is_hvac_mode", {"hvac_mode": [HVACMode.HEAT]}, True, True),
("climate.target_humidity", _HUMIDITY_THRESHOLD, True, True),
("climate.target_temperature", _TEMPERATURE_THRESHOLD, True, True),
],
)
async def test_climate_condition_options_validation(
+25
View File
@@ -67,6 +67,16 @@ async def test_climate_triggers_gated_by_labs_flag(
await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key)
_CHANGED_THRESHOLD = {"threshold": {"type": "any"}}
_HUMIDITY_CROSSED_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}}
_TEMPERATURE_CROSSED_THRESHOLD = {
"threshold": {
"type": "above",
"value": {"number": 20, "unit_of_measurement": UnitOfTemperature.CELSIUS},
}
}
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_key", "base_options", "supports_behavior", "supports_duration"),
@@ -76,6 +86,21 @@ async def test_climate_triggers_gated_by_labs_flag(
("climate.started_heating", {}, True, True),
("climate.turned_off", {}, True, True),
("climate.turned_on", {}, True, True),
("climate.hvac_mode_changed", {"hvac_mode": [HVACMode.HEAT]}, True, True),
("climate.target_humidity_changed", _CHANGED_THRESHOLD, False, False),
(
"climate.target_humidity_crossed_threshold",
_HUMIDITY_CROSSED_THRESHOLD,
True,
True,
),
("climate.target_temperature_changed", _CHANGED_THRESHOLD, False, False),
(
"climate.target_temperature_crossed_threshold",
_TEMPERATURE_CROSSED_THRESHOLD,
True,
True,
),
],
)
async def test_climate_trigger_options_validation(
+10 -4
View File
@@ -25,11 +25,17 @@ async def target_counters(hass: HomeAssistant) -> dict[str, list[str]]:
return await target_entities(hass, "counter")
async def test_counter_condition_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
@pytest.mark.parametrize(
"condition",
[
"counter.is_value",
],
)
async def test_counter_conditions_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str
) -> None:
"""Test the counter condition is gated by the labs flag."""
await assert_condition_gated_by_labs_flag(hass, caplog, "counter.is_value")
"""Test the counter conditions are gated by the labs flag."""
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
_PLAIN_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}}
+10 -3
View File
@@ -39,9 +39,16 @@ async def target_covers(hass: HomeAssistant) -> dict[str, list[str]]:
@pytest.mark.parametrize(
"condition",
[
condition
for _, is_open, is_closed in DEVICE_CLASS_CONDITIONS
for condition in (is_open, is_closed)
"cover.awning_is_closed",
"cover.awning_is_open",
"cover.blind_is_closed",
"cover.blind_is_open",
"cover.curtain_is_closed",
"cover.curtain_is_open",
"cover.shade_is_closed",
"cover.shade_is_open",
"cover.shutter_is_closed",
"cover.shutter_is_open",
],
)
async def test_cover_conditions_gated_by_labs_flag(
+10 -3
View File
@@ -38,9 +38,16 @@ async def target_covers(hass: HomeAssistant) -> dict[str, list[str]]:
@pytest.mark.parametrize(
"trigger_key",
[
trigger
for _, opened, closed in DEVICE_CLASS_TRIGGERS
for trigger in (opened, closed)
"cover.awning_closed",
"cover.awning_opened",
"cover.blind_closed",
"cover.blind_opened",
"cover.curtain_closed",
"cover.curtain_opened",
"cover.shade_closed",
"cover.shade_opened",
"cover.shutter_closed",
"cover.shutter_opened",
],
)
async def test_cover_triggers_gated_by_labs_flag(
@@ -64,6 +64,9 @@ async def test_humidifier_conditions_gated_by_labs_flag(
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
_HUMIDITY_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}}
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_key", "base_options", "supports_behavior", "supports_duration"),
@@ -72,6 +75,8 @@ async def test_humidifier_conditions_gated_by_labs_flag(
("humidifier.is_on", {}, True, True),
("humidifier.is_drying", {}, True, True),
("humidifier.is_humidifying", {}, True, True),
("humidifier.is_mode", {"mode": ["normal"]}, True, True),
("humidifier.is_target_humidity", _HUMIDITY_THRESHOLD, True, True),
],
)
async def test_humidifier_condition_options_validation(
@@ -68,6 +68,7 @@ async def test_humidifier_triggers_gated_by_labs_flag(
("humidifier.started_humidifying", {}, True, True),
("humidifier.turned_on", {}, True, True),
("humidifier.turned_off", {}, True, True),
("humidifier.mode_changed", {"mode": ["normal"]}, True, True),
],
)
async def test_humidifier_trigger_options_validation(
@@ -56,12 +56,16 @@ async def test_illuminance_conditions_gated_by_labs_flag(
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
_ILLUMINANCE_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}}
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_key", "base_options", "supports_behavior", "supports_duration"),
[
("illuminance.is_detected", {}, True, True),
("illuminance.is_not_detected", {}, True, True),
("illuminance.is_value", _ILLUMINANCE_THRESHOLD, True, True),
],
)
async def test_illuminance_condition_options_validation(
@@ -58,12 +58,18 @@ async def test_illuminance_triggers_gated_by_labs_flag(
await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key)
_CHANGED_THRESHOLD = {"threshold": {"type": "any"}}
_CROSSED_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}}
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_key", "base_options", "supports_behavior", "supports_duration"),
[
("illuminance.detected", {}, True, True),
("illuminance.cleared", {}, True, True),
("illuminance.changed", _CHANGED_THRESHOLD, False, False),
("illuminance.crossed_threshold", _CROSSED_THRESHOLD, True, True),
],
)
async def test_illuminance_trigger_options_validation(
+14 -14
View File
@@ -30,6 +30,8 @@ MOCK_CONFIG = {
"folder": "INBOX",
"search": "UnSeen UnDeleted",
"event_message_data": ["text", "headers"],
"ssl_cipher_list": "python_default",
"verify_ssl": True,
}
MOCK_OPTIONS = {
@@ -301,7 +303,7 @@ async def test_reauth_failed_conn_error(hass: HomeAssistant) -> None:
async def test_options_form(hass: HomeAssistant) -> None:
"""Test we show the options form."""
"""Test the options form."""
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG)
entry.add_to_hass(hass)
@@ -381,7 +383,7 @@ async def test_key_options_in_options_form(hass: HomeAssistant) -> None:
@pytest.mark.parametrize(
("advanced_options", "assert_result"),
("test_options", "assert_result"),
[
({"max_message_size": 8192}, FlowResultType.CREATE_ENTRY),
({"max_message_size": 1024}, FlowResultType.FORM),
@@ -407,12 +409,12 @@ async def test_key_options_in_options_form(hass: HomeAssistant) -> None:
"enable_push_false",
],
)
async def test_advanced_options_form(
async def test_options_flow_when_connection_fails(
hass: HomeAssistant,
advanced_options: dict[str, str],
test_options: dict[str, str],
assert_result: FlowResultType,
) -> None:
"""Test we show the advanced options."""
"""Test the options flow when the connection fails."""
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG)
entry.add_to_hass(hass)
@@ -420,14 +422,14 @@ async def test_advanced_options_form(
result = await hass.config_entries.options.async_init(
entry.entry_id,
context={"source": config_entries.SOURCE_USER, "show_advanced_options": True},
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
new_config = MOCK_OPTIONS.copy()
new_config.update(advanced_options)
new_config.update(test_options)
try:
with patch(
@@ -462,7 +464,7 @@ async def test_config_flow_with_cipherlist_and_ssl_verify(
config["verify_ssl"] = verify_ssl
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER, "show_advanced_options": True},
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
@@ -494,7 +496,7 @@ async def test_config_flow_with_event_message_data(
config["event_message_data"] = event_message_data
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER, "show_advanced_options": False},
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
@@ -517,16 +519,14 @@ async def test_config_flow_with_event_message_data(
assert len(mock_setup_entry.mock_calls) == 1
async def test_config_flow_from_with_advanced_settings(
async def test_cipher_settings_in_config_flow(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test if advanced settings show correctly."""
"""Test cipher settings in config flow."""
config = MOCK_CONFIG.copy()
config["ssl_cipher_list"] = "python_default"
config["verify_ssl"] = True
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER, "show_advanced_options": True},
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
@@ -72,6 +72,8 @@ async def test_entry_diagnostics(
],
"search": "UnSeen UnDeleted",
"custom_event_data_template": "{{ 4 * 4 }}",
"ssl_cipher_list": "python_default",
"verify_ssl": True,
}
expected_event_data = {
"date": "2023-03-24T13:52:00+01:00",
+4
View File
@@ -47,12 +47,16 @@ async def test_light_conditions_gated_by_labs_flag(
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
_BRIGHTNESS_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}}
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_key", "base_options", "supports_behavior", "supports_duration"),
[
("light.is_off", {}, True, True),
("light.is_on", {}, True, True),
("light.is_brightness", _BRIGHTNESS_THRESHOLD, True, True),
],
)
async def test_light_condition_options_validation(
+13
View File
@@ -53,12 +53,25 @@ async def test_light_triggers_gated_by_labs_flag(
await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key)
_CHANGED_THRESHOLD = {"threshold": {"type": "any"}}
_BRIGHTNESS_CROSSED_THRESHOLD = {
"threshold": {"type": "above", "value": {"number": 50}}
}
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_key", "base_options", "supports_behavior", "supports_duration"),
[
("light.turned_on", {}, True, True),
("light.turned_off", {}, True, True),
("light.brightness_changed", _CHANGED_THRESHOLD, False, False),
(
"light.brightness_crossed_threshold",
_BRIGHTNESS_CROSSED_THRESHOLD,
True,
True,
),
],
)
async def test_light_trigger_options_validation(
@@ -56,12 +56,16 @@ async def test_moisture_conditions_gated_by_labs_flag(
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
_MOISTURE_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}}
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_key", "base_options", "supports_behavior", "supports_duration"),
[
("moisture.is_detected", {}, True, True),
("moisture.is_not_detected", {}, True, True),
("moisture.is_value", _MOISTURE_THRESHOLD, True, True),
],
)
async def test_moisture_condition_options_validation(
@@ -58,12 +58,18 @@ async def test_moisture_triggers_gated_by_labs_flag(
await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key)
_CHANGED_THRESHOLD = {"threshold": {"type": "any"}}
_CROSSED_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}}
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_key", "base_options", "supports_behavior", "supports_duration"),
[
("moisture.detected", {}, True, True),
("moisture.cleared", {}, True, True),
("moisture.changed", _CHANGED_THRESHOLD, False, False),
("moisture.crossed_threshold", _CROSSED_THRESHOLD, True, True),
],
)
async def test_moisture_trigger_options_validation(
+7 -19
View File
@@ -88,9 +88,7 @@ async def test_mqtt_await_ack_at_disconnect(hass: HomeAssistant) -> None:
mid = 100
rc = 0
with patch(
"homeassistant.components.mqtt.async_client.AsyncMQTTClient"
) as mock_client:
with patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client:
mqtt_client = mock_client.return_value
mqtt_client.connect = MagicMock(
return_value=0,
@@ -1305,9 +1303,7 @@ async def test_publish_error(
entry.add_to_hass(hass)
# simulate an Out of memory error
with patch(
"homeassistant.components.mqtt.async_client.AsyncMQTTClient"
) as mock_client:
with patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client:
mock_client().connect = lambda **kwargs: 1
mock_client().publish().rc = 1
assert await hass.config_entries.async_setup(entry.entry_id)
@@ -1404,9 +1400,7 @@ async def test_setup_mqtt_client_clean_session_and_protocol(
clean_session: bool | None,
) -> None:
"""Test MQTT client clean_session and protocol setup."""
with patch(
"homeassistant.components.mqtt.async_client.AsyncMQTTClient"
) as mock_client:
with patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client:
await mqtt_mock_entry()
# check if clean_session was correctly
@@ -1470,9 +1464,7 @@ async def test_handle_mqtt_timeout_on_callback(
mid = 102
rc = 0
with patch(
"homeassistant.components.mqtt.async_client.AsyncMQTTClient"
) as mock_client:
with patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client:
def _mock_ack(topic: str, qos: int = 0) -> tuple[int, int]:
# Handle ACK for subscribe normally
@@ -1539,9 +1531,7 @@ async def test_setup_raises_config_entry_not_ready_if_no_connect_broker(
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.mqtt.async_client.AsyncMQTTClient"
) as mock_client:
with patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client:
mock_client().connect = MagicMock(side_effect=exception)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@@ -1576,9 +1566,7 @@ async def test_setup_uses_certificate_on_certificate_set_to_auto_and_insecure(
def mock_tls_insecure_set(insecure_param) -> None:
insecure_check["insecure"] = insecure_param
with patch(
"homeassistant.components.mqtt.async_client.AsyncMQTTClient"
) as mock_client:
with patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client:
mock_client().tls_set = mock_tls_set
mock_client().tls_insecure_set = mock_tls_insecure_set
await mqtt_mock_entry()
@@ -1618,7 +1606,7 @@ async def test_client_id_is_set(
) -> None:
"""Test setup defaults for tls."""
with patch(
"homeassistant.components.mqtt.async_client.AsyncMQTTClient"
"homeassistant.components.mqtt.client.AsyncMQTTClient"
) as async_client_mock:
await mqtt_mock_entry()
await hass.async_block_till_done()
+2 -6
View File
@@ -254,9 +254,7 @@ def mock_try_connection_success() -> Generator[MqttMockPahoClient]:
mock_client().on_unsubscribe(mock_client, 0, mid, [MockMqttReasonCode()], None)
return (0, mid)
with patch(
"homeassistant.components.mqtt.async_client.AsyncMQTTClient"
) as mock_client:
with patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client:
mock_client().loop_start = loop_start
mock_client().subscribe = _subscribe
mock_client().unsubscribe = _unsubscribe
@@ -270,9 +268,7 @@ def mock_try_connection_time_out() -> Generator[MagicMock]:
# Patch prevent waiting 5 sec for a timeout
with (
patch(
"homeassistant.components.mqtt.async_client.AsyncMQTTClient"
) as mock_client,
patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client,
patch("homeassistant.components.mqtt.config_flow.MQTT_TIMEOUT", 0),
):
mock_client().loop_start = lambda *args: 1
+2 -6
View File
@@ -10,6 +10,7 @@ from typing import Any, TypedDict
from unittest.mock import ANY, MagicMock, Mock, mock_open, patch
from freezegun.api import FrozenDateTimeFactory
from paho.mqtt.client import MQTTMessage
import pytest
import voluptuous as vol
@@ -700,11 +701,6 @@ async def test_receiving_message_with_non_utf8_topic_gets_logged(
await mqtt_mock_entry()
await mqtt.async_subscribe(hass, "test-topic", record_calls)
# Local import to avoid processing MQTT modules when running a testcase
# which does not use MQTT.
from paho.mqtt.client import MQTTMessage # noqa: PLC0415
from homeassistant.components.mqtt.models import MqttData # noqa: PLC0415
msg = MQTTMessage(topic=b"tasmota/discovery/18FE34E0B760\xcc\x02")
@@ -1910,7 +1906,7 @@ async def test_link_config_entry(
assert _check_entities() == 2
# reload entry and assert again
with patch("homeassistant.components.mqtt.async_client.AsyncMQTTClient"):
with patch("homeassistant.components.mqtt.client.AsyncMQTTClient"):
await hass.config_entries.async_reload(mqtt_config_entry.entry_id)
await hass.async_block_till_done()
+4
View File
@@ -39,11 +39,15 @@ async def test_todo_conditions_gated_by_labs_flag(
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
_TODO_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 5}}}
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_key", "base_options", "supports_behavior", "supports_duration"),
[
("todo.all_completed", {}, True, True),
("todo.incomplete", _TODO_THRESHOLD, True, True),
],
)
async def test_todo_condition_options_validation(
+45
View File
@@ -36,6 +36,10 @@ from homeassistant.setup import async_setup_component
from . import MockTodoListEntity, create_mock_platform
from tests.common import async_mock_service, mock_device_registry
from tests.components.common import (
assert_trigger_gated_by_labs_flag,
assert_trigger_options_supported,
)
TODO_ENTITY_ID1 = "todo.list_one"
TODO_ENTITY_ID2 = "todo.list_two"
@@ -122,6 +126,47 @@ def service_calls(hass: HomeAssistant) -> list[ServiceCall]:
return async_mock_service(hass, "test", "item_added")
@pytest.mark.parametrize(
"trigger_key",
[
"todo.item_added",
"todo.item_completed",
"todo.item_removed",
],
)
async def test_todo_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the todo triggers are gated by the labs flag."""
await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_key", "base_options", "supports_behavior", "supports_duration"),
[
("todo.item_added", None, False, False),
("todo.item_completed", None, False, False),
("todo.item_removed", None, False, False),
],
)
async def test_todo_trigger_options_validation(
hass: HomeAssistant,
trigger_key: str,
base_options: dict[str, Any] | None,
supports_behavior: bool,
supports_duration: bool,
) -> None:
"""Test that todo triggers support the expected options."""
await assert_trigger_options_supported(
hass,
trigger_key,
base_options,
supports_behavior=supports_behavior,
supports_duration=supports_duration,
)
def _assert_service_calls(
service_calls: list[ServiceCall], expected_calls: list[dict[str, Any]]
) -> None:
@@ -72,12 +72,27 @@ async def test_water_heater_conditions_gated_by_labs_flag(
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
_TEMPERATURE_THRESHOLD = {
"threshold": {
"type": "above",
"value": {"number": 20, "unit_of_measurement": UnitOfTemperature.CELSIUS},
}
}
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_key", "base_options", "supports_behavior", "supports_duration"),
[
("water_heater.is_off", {}, True, True),
("water_heater.is_on", {}, True, True),
(
"water_heater.is_operation_mode",
{"operation_mode": [STATE_ECO]},
True,
True,
),
("water_heater.is_target_temperature", _TEMPERATURE_THRESHOLD, True, True),
],
)
async def test_water_heater_condition_options_validation(
@@ -65,12 +65,39 @@ async def test_water_heater_triggers_gated_by_labs_flag(
await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key)
_CHANGED_THRESHOLD = {"threshold": {"type": "any"}}
_CROSSED_THRESHOLD = {
"threshold": {
"type": "above",
"value": {"number": 20, "unit_of_measurement": UnitOfTemperature.CELSIUS},
}
}
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_key", "base_options", "supports_behavior", "supports_duration"),
[
("water_heater.turned_off", {}, True, True),
("water_heater.turned_on", {}, True, True),
(
"water_heater.operation_mode_changed",
{"operation_mode": [STATE_ECO]},
True,
True,
),
(
"water_heater.target_temperature_changed",
_CHANGED_THRESHOLD,
False,
False,
),
(
"water_heater.target_temperature_crossed_threshold",
_CROSSED_THRESHOLD,
True,
True,
),
],
)
async def test_water_heater_trigger_options_validation(
+1 -3
View File
@@ -1057,9 +1057,7 @@ def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient]:
self.mid = mid
self.rc = 0
with patch(
"homeassistant.components.mqtt.async_client.AsyncMQTTClient"
) as mock_client:
with patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client:
# The below use a call_soon for the on_publish/on_subscribe/on_unsubscribe
# callbacks to simulate the behavior of the real MQTT client which will
# not be synchronous.
+83
View File
@@ -57,6 +57,7 @@ from homeassistant.helpers.trigger import (
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
EntityTriggerBase,
PluggableAction,
StatelessEntityTriggerBase,
Trigger,
TriggerActionRunner,
TriggerConfig,
@@ -3098,6 +3099,88 @@ async def test_make_entity_origin_state_trigger(
assert not trig.is_valid_state(from_state)
class _ActivatedTrigger(StatelessEntityTriggerBase):
"""Test trigger leaf for StatelessEntityTriggerBase."""
_domain_specs = {"test": DomainSpec()}
async def _arm_activated_trigger(
hass: HomeAssistant,
entity_ids: list[str],
calls: list[dict[str, Any]],
) -> CALLBACK_TYPE:
"""Set up _ActivatedTrigger via async_initialize_triggers."""
async def async_get_triggers(
hass: HomeAssistant,
) -> dict[str, type[Trigger]]:
return {"activated": _ActivatedTrigger}
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
trigger_config = {
CONF_PLATFORM: "test.activated",
CONF_TARGET: {CONF_ENTITY_ID: entity_ids},
}
log = logging.getLogger(__name__)
@callback
def action(run_variables: dict[str, Any], context: Context | None = None) -> None:
calls.append(run_variables["trigger"])
validated_config = await async_validate_trigger_config(hass, [trigger_config])
return await async_initialize_triggers(
hass,
validated_config,
action,
domain="test",
name="test_activated",
log_cb=log.log,
)
@pytest.mark.parametrize(
("initial_state", "sequence", "expected_calls"),
[
(STATE_UNKNOWN, ["2026-05-06T12:00:00+00:00", "2026-05-06T12:00:01+00:00"], 2),
(STATE_UNAVAILABLE, ["2026-05-06T12:00:00+00:00"], 0),
("2026-05-06T12:00:00+00:00", [STATE_UNAVAILABLE], 0),
("2026-05-06T12:00:00+00:00", [STATE_UNKNOWN], 0),
("2026-05-06T12:00:00+00:00", ["2026-05-06T12:00:00+00:00"], 0),
],
)
async def test_stateless_entity_trigger(
hass: HomeAssistant,
initial_state: str,
sequence: list[str],
expected_calls: int,
) -> None:
"""Test StatelessEntityTriggerBase end-to-end via a mocked platform.
StatelessEntityTriggerBase covers entities (buttons, scenes,
doorbells, events) that have no meaningful prior state STATE_UNKNOWN
must be a valid origin so the first activation after startup fires,
but UNAVAILABLE/UNKNOWN are never valid target states.
"""
entity_id = "test.bell"
hass.states.async_set(entity_id, initial_state)
await hass.async_block_till_done()
calls: list[dict[str, Any]] = []
unsub = await _arm_activated_trigger(hass, [entity_id], calls)
for state in sequence:
hass.states.async_set(entity_id, state)
await hass.async_block_till_done()
assert len(calls) == expected_calls
unsub()
class _OffToOnTrigger(EntityTriggerBase):
"""Test trigger that fires when state becomes 'on'."""