Compare commits

..

6 Commits

Author SHA1 Message Date
Daniel Hjelseth Høyer c2ce313ec8 Bump pyTibber to 0.37.5 (#169981)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-05-07 09:41:08 +02:00
Zoltán Farkasdi b8ba1c123d netatmo: add doortag direct category fetch (#169711)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2026-05-07 09:18:39 +02:00
Daniel Hjelseth Høyer 10f1cbb51e Migrate mill to use entry.runtime_data (#169948)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-05-07 09:15:21 +02:00
Christian Lackas e3bcce06bf Bump PyViCare to 2.60.2 (#169918)
Co-authored-by: home-assistant[bot] <78085893+home-assistant[bot]@users.noreply.github.com>
2026-05-07 08:30:41 +02:00
Kamil Breguła 4e0472feb5 Add fixture for Tuya camera (knkaf1d0dytgyhix) (#169967)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
2026-05-07 07:33:28 +02:00
Jan Bouwhuis 046298f2ca No need for a local import of the paho mqtt client (#169925) 2026-05-06 22:45:36 +02:00
37 changed files with 1358 additions and 200 deletions
+18 -3
View File
@@ -1,6 +1,11 @@
"""Provides triggers for counters."""
from homeassistant.const import CONF_MAXIMUM, CONF_MINIMUM
from homeassistant.const import (
CONF_MAXIMUM,
CONF_MINIMUM,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
@@ -36,7 +41,9 @@ class CounterDecrementedTrigger(CounterBaseIntegerTrigger):
"""Trigger for when a counter is decremented."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the counter value decreased."""
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return int(from_state.state) > int(to_state.state)
@@ -44,7 +51,9 @@ class CounterIncrementedTrigger(CounterBaseIntegerTrigger):
"""Trigger for when a counter is incremented."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the counter value increased."""
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return int(from_state.state) < int(to_state.state)
@@ -53,6 +62,12 @@ class CounterValueBaseTrigger(EntityTriggerBase):
_domain_specs = {DOMAIN: DomainSpec()}
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return from_state.state != to_state.state
class CounterMaxReachedTrigger(CounterValueBaseTrigger):
"""Trigger for when a counter reaches its maximum value."""
+4 -2
View File
@@ -2,7 +2,7 @@
from collections.abc import Mapping
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
@@ -28,7 +28,9 @@ class CoverTriggerBase(EntityTriggerBase):
return self._get_value(state) == domain_spec.target_value
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the relevant cover value changed."""
"""Check if the transition is valid for a cover state change."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
if (from_value := self._get_value(from_state)) is None:
return False
return from_value != self._get_value(to_state)
+4 -2
View File
@@ -17,8 +17,10 @@ class DoorbellRangTrigger(StatelessEntityTriggerBase):
_domain_specs = {EVENT_DOMAIN: DomainSpec(device_class=EventDeviceClass.DOORBELL)}
def is_valid_state(self, state: State) -> bool:
"""Check if the event type is ring."""
return state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
"""Check if the entity is available and the event type is ring."""
return super().is_valid_state(state) and (
state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
)
TRIGGERS: dict[str, type[Trigger]] = {
+3 -1
View File
@@ -41,7 +41,9 @@ class EventReceivedTrigger(StatelessEntityTriggerBase):
def is_valid_state(self, state: State) -> bool:
"""Check if the event type matches one of the configured types."""
return state.attributes.get(ATTR_EVENT_TYPE) in self._event_types
return super().is_valid_state(state) and (
state.attributes.get(ATTR_EVENT_TYPE) in self._event_types
)
TRIGGERS: dict[str, type[Trigger]] = {
@@ -1,5 +1,6 @@
"""Provides triggers for media players."""
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 (
@@ -49,7 +50,10 @@ class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase):
)
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the muted-state changed."""
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
if not self._has_volume_attributes(to_state):
return False
+10 -13
View File
@@ -5,30 +5,31 @@ from datetime import timedelta
from mill import Mill
from mill_local import Mill as MillLocal
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CLOUD, CONNECTION_TYPE, DOMAIN, LOCAL
from .coordinator import MillDataUpdateCoordinator, MillHistoricDataUpdateCoordinator
from .coordinator import (
MillConfigEntry,
MillDataUpdateCoordinator,
MillHistoricDataUpdateCoordinator,
)
PLATFORMS = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR]
__all__ = ["CLOUD", "CONNECTION_TYPE", "DOMAIN", "LOCAL"]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: MillConfigEntry) -> bool:
"""Set up the Mill heater."""
hass.data.setdefault(DOMAIN, {LOCAL: {}, CLOUD: {}})
if entry.data.get(CONNECTION_TYPE) == LOCAL:
mill_data_connection = MillLocal(
entry.data[CONF_IP_ADDRESS],
websession=async_get_clientsession(hass),
)
update_interval = timedelta(seconds=15)
key = entry.data[CONF_IP_ADDRESS]
conn_type = LOCAL
else:
mill_data_connection = Mill(
entry.data[CONF_USERNAME],
@@ -36,8 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
websession=async_get_clientsession(hass),
)
update_interval = timedelta(seconds=30)
key = entry.data[CONF_USERNAME]
conn_type = CLOUD
historic_data_coordinator = MillHistoricDataUpdateCoordinator(
hass,
@@ -56,14 +55,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
await data_coordinator.async_config_entry_first_refresh()
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
hass.data[DOMAIN][conn_type][key] = data_coordinator
entry.runtime_data = data_coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: MillConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+5 -15
View File
@@ -1,5 +1,4 @@
"""Support for mill wifi-enabled home heaters."""
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from typing import Any
@@ -14,14 +13,7 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_TEMPERATURE,
CONF_IP_ADDRESS,
CONF_USERNAME,
PRECISION_TENTHS,
UnitOfTemperature,
)
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
@@ -33,7 +25,6 @@ from .const import (
ATTR_COMFORT_TEMP,
ATTR_ROOM_NAME,
ATTR_SLEEP_TEMP,
CLOUD,
CONNECTION_TYPE,
DOMAIN,
LOCAL,
@@ -42,7 +33,7 @@ from .const import (
MIN_TEMP,
SERVICE_SET_ROOM_TEMP,
)
from .coordinator import MillDataUpdateCoordinator
from .coordinator import MillConfigEntry, MillDataUpdateCoordinator
from .entity import MillBaseEntity
SET_ROOM_TEMP_SCHEMA = vol.Schema(
@@ -57,17 +48,16 @@ SET_ROOM_TEMP_SCHEMA = vol.Schema(
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MillConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Mill climate."""
mill_data_coordinator = entry.runtime_data
if entry.data.get(CONNECTION_TYPE) == LOCAL:
mill_data_coordinator = hass.data[DOMAIN][LOCAL][entry.data[CONF_IP_ADDRESS]]
async_add_entities([LocalMillHeater(mill_data_coordinator)])
return
mill_data_coordinator = hass.data[DOMAIN][CLOUD][entry.data[CONF_USERNAME]]
entities = [
MillHeater(mill_data_coordinator, mill_device)
for mill_device in mill_data_coordinator.data.values()
@@ -57,6 +57,9 @@ class MillDataUpdateCoordinator(DataUpdateCoordinator):
)
type MillConfigEntry = ConfigEntry[MillDataUpdateCoordinator]
class MillHistoricDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching Mill historic data."""
+5 -10
View File
@@ -3,28 +3,23 @@
from mill import Heater, MillDevice
from homeassistant.components.number import NumberDeviceClass, NumberEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_USERNAME, UnitOfPower
from homeassistant.const import UnitOfPower
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CLOUD, CONNECTION_TYPE, DOMAIN
from .coordinator import MillDataUpdateCoordinator
from .const import CLOUD, CONNECTION_TYPE
from .coordinator import MillConfigEntry, MillDataUpdateCoordinator
from .entity import MillBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MillConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Mill Number."""
if entry.data.get(CONNECTION_TYPE) == CLOUD:
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
mill_data_coordinator: MillDataUpdateCoordinator = hass.data[DOMAIN][CLOUD][
entry.data[CONF_USERNAME]
]
mill_data_coordinator = entry.runtime_data
async_add_entities(
MillNumber(mill_data_coordinator, mill_device)
+4 -12
View File
@@ -1,5 +1,4 @@
"""Support for mill wifi-enabled home heaters."""
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
import mill
@@ -9,12 +8,9 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONF_IP_ADDRESS,
CONF_USERNAME,
PERCENTAGE,
EntityCategory,
UnitOfEnergy,
@@ -29,11 +25,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
BATTERY,
CLOUD,
CONNECTION_TYPE,
CONSUMPTION_TODAY,
CONSUMPTION_YEAR,
DOMAIN,
ECO2,
HUMIDITY,
LOCAL,
@@ -41,7 +35,7 @@ from .const import (
TEMPERATURE,
TVOC,
)
from .coordinator import MillDataUpdateCoordinator
from .coordinator import MillConfigEntry, MillDataUpdateCoordinator
from .entity import MillBaseEntity
HEATER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
@@ -146,13 +140,13 @@ SOCKET_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MillConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Mill sensor."""
if entry.data.get(CONNECTION_TYPE) == LOCAL:
mill_data_coordinator = hass.data[DOMAIN][LOCAL][entry.data[CONF_IP_ADDRESS]]
mill_data_coordinator = entry.runtime_data
if entry.data.get(CONNECTION_TYPE) == LOCAL:
async_add_entities(
LocalMillSensor(
mill_data_coordinator,
@@ -162,8 +156,6 @@ async def async_setup_entry(
)
return
mill_data_coordinator = hass.data[DOMAIN][CLOUD][entry.data[CONF_USERNAME]]
entities = [
MillSensor(
mill_data_coordinator,
+3 -23
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
@@ -323,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
@@ -561,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.
@@ -705,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
@@ -763,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:
@@ -1265,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",
@@ -1312,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",
@@ -1360,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
@@ -67,25 +67,11 @@ OPENING_CATEGORY_TO_DEVICE_CLASS: Final[dict[str | None, BinarySensorDeviceClass
def get_opening_category(netatmo_device: NetatmoDevice) -> str:
"""Helper function to get opening category from Netatmo API raw data."""
"""Helper function to get opening category for doortag."""
# Iterate through each home in the raw data.
for home in netatmo_device.data_handler.account.raw_data["homes"]:
# Check if the modules list exists for the current home.
if "modules" in home:
# Iterate through each module to find a matching ID.
for module in home["modules"]:
if module["id"] == netatmo_device.device.entity_id:
# We found the matching device. Get its category.
if module.get("category") is not None:
return cast(str, module["category"])
raise ValueError(
f"Device {netatmo_device.device.entity_id} found, "
"but 'category' is missing in raw data."
)
raise ValueError(
f"Device {netatmo_device.device.entity_id} not found in Netatmo raw data."
return (
getattr(netatmo_device.device, "doortag_category", None)
or DOORTAG_CATEGORY_OTHER
)
+5 -2
View File
@@ -1,6 +1,6 @@
"""Provides triggers for schedules."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
@@ -20,7 +20,10 @@ class ScheduleBackToBackTrigger(EntityTransitionTriggerBase):
_to_states = {STATE_ON}
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the origin matches and the next event changed."""
"""Check if the origin state matches the expected ones."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
from_next_event = from_state.attributes.get(ATTR_NEXT_EVENT)
to_next_event = to_state.attributes.get(ATTR_NEXT_EVENT)
+12 -1
View File
@@ -1,7 +1,8 @@
"""Provides triggers for selects."""
from homeassistant.components.input_select import DOMAIN as INPUT_SELECT_DOMAIN
from homeassistant.core import HomeAssistant
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,
@@ -18,6 +19,16 @@ class SelectionChangedTrigger(EntityTriggerBase):
_domain_specs = {DOMAIN: DomainSpec(), INPUT_SELECT_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 the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
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]] = {
"selection_changed": SelectionChangedTrigger,
+12 -1
View File
@@ -1,7 +1,8 @@
"""Provides triggers for text and input_text entities."""
from homeassistant.components.input_text import DOMAIN as INPUT_TEXT_DOMAIN
from homeassistant.core import HomeAssistant
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,
@@ -18,6 +19,16 @@ class TextChangedTrigger(EntityTriggerBase):
_domain_specs = {DOMAIN: DomainSpec(), INPUT_TEXT_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 the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
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]] = {
"changed": TextChangedTrigger,
@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["tibber"],
"requirements": ["pyTibber==0.37.4"]
"requirements": ["pyTibber==0.37.5"]
}
@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["PyViCare"],
"requirements": ["PyViCare==2.60.1"]
"requirements": ["PyViCare==2.60.2"]
}
+42 -44
View File
@@ -353,14 +353,9 @@ class EntityTriggerBase(Trigger):
"""Trigger for entity state changes."""
_domain_specs: Mapping[str, DomainSpec]
# States filtered from the to_state pre-filter (and `_should_include`).
_excluded_states: Final[frozenset[str]] = frozenset(
{STATE_UNAVAILABLE, STATE_UNKNOWN}
)
# States filtered from the from_state pre-filter. Defaults to
# `_excluded_states`. Subclasses can override to relax the origin
# check.
_excluded_from_states: ClassVar[frozenset[str]] = _excluded_states
_schema: vol.Schema = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST
# When True, indirect target expansion (via device/area/floor) skips
# entities with an entity_category.
@@ -394,28 +389,13 @@ class EntityTriggerBase(Trigger):
return state.state
return state.attributes.get(domain_spec.value_source)
@abc.abstractmethod
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the transition should fire the trigger.
Called only after `from_state.state` has been filtered against
`_excluded_from_states` and `to_state.state` against
`_excluded_states`, so subclasses don't need to repeat those
checks. Default: any state change. Override to add semantics
(specific from/to states, value changed across a threshold,
etc.).
"""
return from_state.state != to_state.state
"""Check if the origin state is valid and the state has changed."""
@abc.abstractmethod
def is_valid_state(self, state: State) -> bool:
"""Check if the state is a target state for the trigger.
Called only after `state.state` has been filtered against
`_excluded_states`, so subclasses don't need to repeat that
check. Default: any non-excluded state is a target. Override
to restrict (specific to_states, value within a threshold,
etc.).
"""
return True
"""Check if the new state matches the expected state(s)."""
def _should_include(self, state: State) -> bool:
"""Check if an entity should participate in all/count checks.
@@ -493,26 +473,19 @@ class EntityTriggerBase(Trigger):
)
return matches >= 1
# Behavior any: check the individual entity's state
if not to_state or to_state.state in self._excluded_states:
if not to_state:
return False
return self.is_valid_state(to_state)
if not from_state or not to_state:
return
# The trigger should never fire if the new state is excluded
# or not a target state.
if to_state.state in self._excluded_states or not self.is_valid_state(
to_state
):
# The trigger should never fire if the new state is not valid
if not self.is_valid_state(to_state):
return
# The trigger should never fire if the origin state is excluded
# or the transition is not valid.
if (
from_state.state in self._excluded_from_states
or not self.is_valid_transition(from_state, to_state)
):
# The trigger should never fire if the transition is not valid
if not self.is_valid_transition(from_state, to_state):
return
if behavior == BEHAVIOR_LAST:
@@ -597,7 +570,10 @@ class EntityTargetStateTriggerBase(EntityTriggerBase):
_to_states: set[str]
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check the value changed and the origin was not already a target state."""
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
from_value = self._get_tracked_value(from_state)
return (
from_value != self._get_tracked_value(to_state)
@@ -617,6 +593,9 @@ class EntityTransitionTriggerBase(EntityTriggerBase):
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state matches the expected ones."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
from_value = self._get_tracked_value(from_state)
return (
from_value != self._get_tracked_value(to_state)
@@ -641,8 +620,10 @@ class EntityOriginStateTriggerBase(EntityTriggerBase):
)
def is_valid_state(self, state: State) -> bool:
"""Check that the new state is different from the origin state."""
return bool(self._get_tracked_value(state) != self._from_state)
"""Check if the new state is valid."""
return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) and bool(
self._get_tracked_value(state) != self._from_state
)
class StatelessEntityTriggerBase(EntityTriggerBase):
@@ -650,12 +631,23 @@ class StatelessEntityTriggerBase(EntityTriggerBase):
Used for stateless entities (buttons, scenes, doorbells, events)
whose `state.state` is just a timestamp of the last activation.
`STATE_UNKNOWN` is a legitimate prior state — the first activation
after startup must still fire the trigger.
"""
_schema: vol.Schema = ENTITY_STATE_TRIGGER_SCHEMA
_excluded_from_states: ClassVar[frozenset[str]] = frozenset({STATE_UNAVAILABLE})
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(
@@ -834,7 +826,10 @@ class EntityNumericalStateChangedTriggerBase(EntityNumericalStateTriggerBase):
_schema = NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the tracked numeric value has changed."""
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return self._get_tracked_value(from_state) != self._get_tracked_value(to_state)
@@ -893,7 +888,10 @@ class EntityNumericalStateCrossedThresholdTriggerBase(EntityNumericalStateTrigge
_schema = NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the tracked value crossed into the threshold range."""
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return not self.is_valid_state(from_state)
+2 -2
View File
@@ -99,7 +99,7 @@ PyTransportNSW==0.1.1
PyTurboJPEG==1.8.3
# homeassistant.components.vicare
PyViCare==2.60.1
PyViCare==2.60.2
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.14.3
@@ -1947,7 +1947,7 @@ pyRFXtrx==0.31.1
pySDCP==1
# homeassistant.components.tibber
pyTibber==0.37.4
pyTibber==0.37.5
# homeassistant.components.dlink
pyW215==0.8.0
+2 -2
View File
@@ -96,7 +96,7 @@ PyTransportNSW==0.1.1
PyTurboJPEG==1.8.3
# homeassistant.components.vicare
PyViCare==2.60.1
PyViCare==2.60.2
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.14.3
@@ -1690,7 +1690,7 @@ pyHomee==1.3.8
pyRFXtrx==0.31.1
# homeassistant.components.tibber
pyTibber==0.37.4
pyTibber==0.37.5
# homeassistant.components.dlink
pyW215==0.8.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):
+3 -1
View File
@@ -4,6 +4,7 @@ import asyncio
from unittest.mock import patch
from homeassistant.components import mill
from homeassistant.components.mill.coordinator import MillDataUpdateCoordinator
from homeassistant.components.recorder import Recorder
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
@@ -156,7 +157,8 @@ async def test_unload_entry(recorder_mock: Recorder, hass: HomeAssistant) -> Non
):
assert await async_setup_component(hass, "mill", {})
assert isinstance(entry.runtime_data, MillDataUpdateCoordinator)
assert await hass.config_entries.async_unload(entry.entry_id)
assert unload_entry.call_count == 3
assert entry.entry_id not in hass.data[mill.DOMAIN]
+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()
@@ -0,0 +1,289 @@
{
"name": "Security Camera",
"category": "sp",
"product_id": "knkaf1d0dytgyhix",
"product_name": "Security Camera",
"online": true,
"sub": false,
"time_zone": "+02:00",
"active_time": "2025-09-11T13:22:03+00:00",
"create_time": "2025-09-11T13:22:03+00:00",
"update_time": "2025-09-11T13:22:03+00:00",
"function": {
"basic_indicator": {
"type": "Boolean",
"value": "{}"
},
"basic_flip": {
"type": "Boolean",
"value": "{}"
},
"basic_osd": {
"type": "Boolean",
"value": "{}"
},
"basic_private": {
"type": "Boolean",
"value": "{}"
},
"motion_sensitivity": {
"type": "Enum",
"value": "{\"range\":[\"0\",\"1\",\"2\"]}"
},
"basic_wdr": {
"type": "Boolean",
"value": "{}"
},
"basic_nightvision": {
"type": "Enum",
"value": "{\"range\":[\"0\",\"1\",\"2\"]}"
},
"sd_format": {
"type": "Boolean",
"value": "{}"
},
"motion_record": {
"type": "Boolean",
"value": "{}"
},
"ipc_auto_siren": {
"type": "Boolean",
"value": "{}"
},
"ipc_sharp": {
"type": "Integer",
"value": "{\"unit\":\"%\",\"min\":0,\"max\":100,\"scale\":0,\"step\":1}"
},
"motion_switch": {
"type": "Boolean",
"value": "{}"
},
"record_switch1": {
"type": "Boolean",
"value": "{}"
},
"decibel_switch": {
"type": "Boolean",
"value": "{}"
},
"decibel_sensitivity": {
"type": "Enum",
"value": "{\"range\":[\"0\",\"1\"]}"
},
"record_switch": {
"type": "Boolean",
"value": "{}"
},
"record_mode": {
"type": "Enum",
"value": "{\"range\":[\"1\",\"2\"]}"
},
"siren_switch": {
"type": "Boolean",
"value": "{}"
},
"device_restart": {
"type": "Boolean",
"value": "{}"
},
"motion_area_switch": {
"type": "Boolean",
"value": "{}"
},
"motion_area": {
"type": "String",
"value": "{\"maxlen\":255}"
},
"ipc_contrast": {
"type": "Integer",
"value": "{\"unit\":\"%\",\"min\":0,\"max\":100,\"scale\":0,\"step\":1}"
},
"ipc_bright": {
"type": "Integer",
"value": "{\"unit\":\"%\",\"min\":0,\"max\":100,\"scale\":0,\"step\":1}"
}
},
"local_strategy": {},
"status_range": {
"basic_indicator": {
"type": "Boolean",
"value": "{}",
"report_type": null
},
"basic_flip": {
"type": "Boolean",
"value": "{}",
"report_type": null
},
"basic_osd": {
"type": "Boolean",
"value": "{}",
"report_type": null
},
"basic_private": {
"type": "Boolean",
"value": "{}",
"report_type": null
},
"motion_sensitivity": {
"type": "Enum",
"value": "{\"range\":[\"0\",\"1\",\"2\"]}",
"report_type": null
},
"basic_wdr": {
"type": "Boolean",
"value": "{}",
"report_type": null
},
"basic_nightvision": {
"type": "Enum",
"value": "{\"range\":[\"0\",\"1\",\"2\"]}",
"report_type": null
},
"sd_storge": {
"type": "String",
"value": "{\"maxlen\":255}",
"report_type": null
},
"sd_status": {
"type": "Integer",
"value": "{\"min\":1,\"max\":5,\"scale\":0,\"step\":1}",
"report_type": null
},
"sd_format": {
"type": "Boolean",
"value": "{}",
"report_type": null
},
"motion_record": {
"type": "Boolean",
"value": "{}",
"report_type": null
},
"movement_detect_pic": {
"type": "Raw",
"value": "{}",
"report_type": null
},
"sd_format_state": {
"type": "Integer",
"value": "{\"min\":-20000,\"max\":200000,\"scale\":0,\"step\":1}",
"report_type": null
},
"ipc_auto_siren": {
"type": "Boolean",
"value": "{}",
"report_type": null
},
"ipc_sharp": {
"type": "Integer",
"value": "{\"unit\":\"%\",\"min\":0,\"max\":100,\"scale\":0,\"step\":1}",
"report_type": null
},
"motion_switch": {
"type": "Boolean",
"value": "{}",
"report_type": null
},
"record_switch1": {
"type": "Boolean",
"value": "{}",
"report_type": null
},
"decibel_switch": {
"type": "Boolean",
"value": "{}",
"report_type": null
},
"decibel_sensitivity": {
"type": "Enum",
"value": "{\"range\":[\"0\",\"1\"]}",
"report_type": null
},
"record_switch": {
"type": "Boolean",
"value": "{}",
"report_type": null
},
"record_mode": {
"type": "Enum",
"value": "{\"range\":[\"1\",\"2\"]}",
"report_type": null
},
"siren_switch": {
"type": "Boolean",
"value": "{}",
"report_type": null
},
"device_restart": {
"type": "Boolean",
"value": "{}",
"report_type": null
},
"motion_area_switch": {
"type": "Boolean",
"value": "{}",
"report_type": null
},
"motion_area": {
"type": "String",
"value": "{\"maxlen\":255}",
"report_type": null
},
"alarm_message": {
"type": "String",
"value": "{}",
"report_type": null
},
"ipc_contrast": {
"type": "Integer",
"value": "{\"unit\":\"%\",\"min\":0,\"max\":100,\"scale\":0,\"step\":1}",
"report_type": null
},
"ipc_bright": {
"type": "Integer",
"value": "{\"unit\":\"%\",\"min\":0,\"max\":100,\"scale\":0,\"step\":1}",
"report_type": null
},
"initiative_message": {
"type": "Raw",
"value": "{}",
"report_type": null
}
},
"status": {
"basic_indicator": true,
"basic_flip": false,
"basic_osd": true,
"basic_private": false,
"motion_sensitivity": "1",
"basic_wdr": false,
"basic_nightvision": "1",
"sd_storge": "896|896|0",
"sd_status": 5,
"sd_format": false,
"motion_record": false,
"movement_detect_pic": "**REDACTED**",
"sd_format_state": 0,
"ipc_auto_siren": false,
"ipc_sharp": 50,
"motion_switch": true,
"record_switch1": false,
"decibel_switch": false,
"decibel_sensitivity": "0",
"record_switch": true,
"record_mode": "1",
"siren_switch": false,
"device_restart": true,
"motion_area_switch": false,
"motion_area": "{\"num\":1,\"region0\":{\"x\":0,\"y\":0,\"xlen\":100,\"ylen\":100}}",
"alarm_message": "**REDACTED**",
"ipc_contrast": 50,
"ipc_bright": 50,
"initiative_message": ""
},
"set_up": true,
"support_local": false,
"quirk": null,
"warnings": null
}
@@ -441,3 +441,59 @@
'state': 'idle',
})
# ---
# name: test_platform_setup_and_discovery[camera.security_camera-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'camera',
'entity_category': None,
'entity_id': 'camera.security_camera',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <CameraEntityFeature: 2>,
'translation_key': None,
'unique_id': 'tuya.xihygtyd0d1faknkps',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[camera.security_camera-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'access_token': '1',
'brand': 'Tuya',
'entity_picture': '/api/camera_proxy/camera.security_camera?token=1',
'friendly_name': 'Security Camera',
'model_name': 'Security Camera',
'motion_detection': True,
'supported_features': <CameraEntityFeature: 2>,
}),
'context': <ANY>,
'entity_id': 'camera.security_camera',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'recording',
})
# ---
@@ -806,3 +806,63 @@
'state': '2023-11-01T12:14:15.000+00:00',
})
# ---
# name: test_platform_setup_and_discovery[event.security_camera_doorbell_message-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'event_types': list([
'triggered',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'event',
'entity_category': None,
'entity_id': 'event.security_camera_doorbell_message',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Doorbell message',
'options': dict({
}),
'original_device_class': <EventDeviceClass.DOORBELL: 'doorbell'>,
'original_icon': None,
'original_name': 'Doorbell message',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'doorbell_message',
'unique_id': 'tuya.xihygtyd0d1faknkpsalarm_message',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[event.security_camera_doorbell_message-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'doorbell',
'event_type': 'triggered',
'event_types': list([
'triggered',
]),
'friendly_name': 'Security Camera Doorbell message',
'message': '',
}),
'context': <ANY>,
'entity_id': 'event.security_camera_doorbell_message',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2023-11-01T12:14:15.000+00:00',
})
# ---
@@ -8865,6 +8865,37 @@
'via_device_id': None,
})
# ---
# name: test_device_registry[xihygtyd0d1faknkps]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'tuya',
'xihygtyd0d1faknkps',
),
}),
'labels': set({
}),
'manufacturer': 'Tuya',
'model': 'Security Camera',
'model_id': 'knkaf1d0dytgyhix',
'name': 'Security Camera',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
})
# ---
# name: test_device_registry[xms6qowipdvjnkdgqdt]
DeviceRegistryEntrySnapshot({
'area_id': None,
@@ -3245,6 +3245,65 @@
'state': 'off',
})
# ---
# name: test_platform_setup_and_discovery[light.security_camera_indicator_light-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'supported_color_modes': list([
<ColorMode.ONOFF: 'onoff'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'light',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'light.security_camera_indicator_light',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Indicator light',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Indicator light',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'tuya.xihygtyd0d1faknkpsbasic_indicator',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[light.security_camera_indicator_light-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'color_mode': <ColorMode.ONOFF: 'onoff'>,
'friendly_name': 'Security Camera Indicator light',
'supported_color_modes': list([
<ColorMode.ONOFF: 'onoff'>,
]),
'supported_features': <LightEntityFeature: 0>,
}),
'context': <ANY>,
'entity_id': 'light.security_camera_indicator_light',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_platform_setup_and_discovery[light.shop_light_5-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -5025,6 +5025,246 @@
'state': 'unknown',
})
# ---
# name: test_platform_setup_and_discovery[select.security_camera_motion_detection_sensitivity-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'options': list([
'0',
'1',
'2',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.security_camera_motion_detection_sensitivity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Motion detection sensitivity',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Motion detection sensitivity',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'motion_sensitivity',
'unique_id': 'tuya.xihygtyd0d1faknkpsmotion_sensitivity',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[select.security_camera_motion_detection_sensitivity-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Security Camera Motion detection sensitivity',
'options': list([
'0',
'1',
'2',
]),
}),
'context': <ANY>,
'entity_id': 'select.security_camera_motion_detection_sensitivity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1',
})
# ---
# name: test_platform_setup_and_discovery[select.security_camera_night_vision-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'options': list([
'0',
'1',
'2',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.security_camera_night_vision',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Night vision',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Night vision',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'basic_nightvision',
'unique_id': 'tuya.xihygtyd0d1faknkpsbasic_nightvision',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[select.security_camera_night_vision-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Security Camera Night vision',
'options': list([
'0',
'1',
'2',
]),
}),
'context': <ANY>,
'entity_id': 'select.security_camera_night_vision',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1',
})
# ---
# name: test_platform_setup_and_discovery[select.security_camera_record_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'options': list([
'1',
'2',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.security_camera_record_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Record mode',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Record mode',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'record_mode',
'unique_id': 'tuya.xihygtyd0d1faknkpsrecord_mode',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[select.security_camera_record_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Security Camera Record mode',
'options': list([
'1',
'2',
]),
}),
'context': <ANY>,
'entity_id': 'select.security_camera_record_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1',
})
# ---
# name: test_platform_setup_and_discovery[select.security_camera_sound_detection_sensitivity-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'options': list([
'0',
'1',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.security_camera_sound_detection_sensitivity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Sound detection sensitivity',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Sound detection sensitivity',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'decibel_sensitivity',
'unique_id': 'tuya.xihygtyd0d1faknkpsdecibel_sensitivity',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[select.security_camera_sound_detection_sensitivity-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Security Camera Sound detection sensitivity',
'options': list([
'0',
'1',
]),
}),
'context': <ANY>,
'entity_id': 'select.security_camera_sound_detection_sensitivity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_platform_setup_and_discovery[select.security_light_indicator_light_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -152,6 +152,57 @@
'state': 'off',
})
# ---
# name: test_platform_setup_and_discovery[siren.security_camera-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'siren',
'entity_category': None,
'entity_id': 'siren.security_camera',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <SirenEntityFeature: 3>,
'translation_key': None,
'unique_id': 'tuya.xihygtyd0d1faknkpssiren_switch',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[siren.security_camera-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Security Camera',
'supported_features': <SirenEntityFeature: 3>,
}),
'context': <ANY>,
'entity_id': 'siren.security_camera',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_platform_setup_and_discovery[siren.siren_veranda-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -9125,6 +9125,406 @@
'state': 'on',
})
# ---
# name: test_platform_setup_and_discovery[switch.security_camera_flip-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.security_camera_flip',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Flip',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Flip',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'flip',
'unique_id': 'tuya.xihygtyd0d1faknkpsbasic_flip',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[switch.security_camera_flip-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Security Camera Flip',
}),
'context': <ANY>,
'entity_id': 'switch.security_camera_flip',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_platform_setup_and_discovery[switch.security_camera_motion_alarm-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.security_camera_motion_alarm',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Motion alarm',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Motion alarm',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'motion_alarm',
'unique_id': 'tuya.xihygtyd0d1faknkpsmotion_switch',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[switch.security_camera_motion_alarm-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Security Camera Motion alarm',
}),
'context': <ANY>,
'entity_id': 'switch.security_camera_motion_alarm',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_platform_setup_and_discovery[switch.security_camera_motion_recording-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.security_camera_motion_recording',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Motion recording',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Motion recording',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'motion_recording',
'unique_id': 'tuya.xihygtyd0d1faknkpsmotion_record',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[switch.security_camera_motion_recording-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Security Camera Motion recording',
}),
'context': <ANY>,
'entity_id': 'switch.security_camera_motion_recording',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_platform_setup_and_discovery[switch.security_camera_privacy_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.security_camera_privacy_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Privacy mode',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Privacy mode',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'privacy_mode',
'unique_id': 'tuya.xihygtyd0d1faknkpsbasic_private',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[switch.security_camera_privacy_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Security Camera Privacy mode',
}),
'context': <ANY>,
'entity_id': 'switch.security_camera_privacy_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_platform_setup_and_discovery[switch.security_camera_sound_detection-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.security_camera_sound_detection',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Sound detection',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Sound detection',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'sound_detection',
'unique_id': 'tuya.xihygtyd0d1faknkpsdecibel_switch',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[switch.security_camera_sound_detection-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Security Camera Sound detection',
}),
'context': <ANY>,
'entity_id': 'switch.security_camera_sound_detection',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_platform_setup_and_discovery[switch.security_camera_time_watermark-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.security_camera_time_watermark',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Time watermark',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Time watermark',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'time_watermark',
'unique_id': 'tuya.xihygtyd0d1faknkpsbasic_osd',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[switch.security_camera_time_watermark-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Security Camera Time watermark',
}),
'context': <ANY>,
'entity_id': 'switch.security_camera_time_watermark',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_platform_setup_and_discovery[switch.security_camera_video_recording-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.security_camera_video_recording',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Video recording',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Video recording',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'video_recording',
'unique_id': 'tuya.xihygtyd0d1faknkpsrecord_switch',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[switch.security_camera_video_recording-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Security Camera Video recording',
}),
'context': <ANY>,
'entity_id': 'switch.security_camera_video_recording',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_platform_setup_and_discovery[switch.security_camera_wide_dynamic_range-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.security_camera_wide_dynamic_range',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Wide dynamic range',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Wide dynamic range',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'wide_dynamic_range',
'unique_id': 'tuya.xihygtyd0d1faknkpsbasic_wdr',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[switch.security_camera_wide_dynamic_range-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Security Camera Wide dynamic range',
}),
'context': <ANY>,
'entity_id': 'switch.security_camera_wide_dynamic_range',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_platform_setup_and_discovery[switch.security_light_child_lock-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
+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.
+8
View File
@@ -2969,6 +2969,10 @@ async def test_make_entity_target_state_trigger(
# Value did not change — not a valid transition
assert not trig.is_valid_transition(from_state, from_state)
# From unavailable — not valid
unavailable = State("light.bed", STATE_UNAVAILABLE, {})
assert not trig.is_valid_transition(unavailable, to_state)
# Value not in to_states — not valid
assert not trig.is_valid_state(wrong_value_state)
@@ -3039,6 +3043,10 @@ async def test_make_entity_transition_trigger(
# No change in tracked value — not a valid transition
assert not trig.is_valid_transition(from_state, from_state)
# From unavailable — not valid
unavailable = State("climate.living", STATE_UNAVAILABLE, {})
assert not trig.is_valid_transition(unavailable, to_state)
@pytest.mark.parametrize(
("domain_specs", "origin", "from_state", "to_state", "wrong_from"),