mirror of
https://github.com/home-assistant/core.git
synced 2026-04-15 22:26:12 +02:00
Compare commits
22 Commits
radio-freq
...
trigger_ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
badefd9707 | ||
|
|
219cfdc99b | ||
|
|
3a28af68c4 | ||
|
|
3f11d9240c | ||
|
|
c0eb70d1af | ||
|
|
77c5f2e175 | ||
|
|
995eafc811 | ||
|
|
c9a2b7cb04 | ||
|
|
0379e7c12f | ||
|
|
ab5ae33290 | ||
|
|
c0bf9a2bd2 | ||
|
|
d862b999ae | ||
|
|
d6be6e8810 | ||
|
|
f397f4c908 | ||
|
|
d58e7862c0 | ||
|
|
84f57f9859 | ||
|
|
c6169ec8eb | ||
|
|
c47cecf350 | ||
|
|
e31f611901 | ||
|
|
bc36b1dda2 | ||
|
|
b3967130f0 | ||
|
|
2960db3d8e |
@@ -8,7 +8,7 @@ repos:
|
||||
- id: ruff-format
|
||||
files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.(py|pyi)$
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.4.1
|
||||
rev: v2.4.2
|
||||
hooks:
|
||||
- id: codespell
|
||||
args:
|
||||
|
||||
@@ -152,6 +152,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
"text",
|
||||
"timer",
|
||||
"todo",
|
||||
"update",
|
||||
"vacuum",
|
||||
"valve",
|
||||
"water_heater",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_name": "Condition passes if",
|
||||
"trigger_behavior_name": "Trigger when"
|
||||
"trigger_behavior_name": "Trigger when",
|
||||
"trigger_for_name": "For at least"
|
||||
},
|
||||
"conditions": {
|
||||
"is_detected": {
|
||||
@@ -45,6 +46,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::motion::common::trigger_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::motion::common::trigger_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Motion cleared"
|
||||
@@ -54,6 +58,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::motion::common::trigger_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::motion::common::trigger_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Motion detected"
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
|
||||
detected:
|
||||
fields: *trigger_common_fields
|
||||
|
||||
@@ -54,7 +54,7 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity):
|
||||
def __init__(
|
||||
self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry
|
||||
) -> None:
|
||||
"""Initialize sensor entiry."""
|
||||
"""Initialize sensor entity."""
|
||||
super().__init__(mm, config_entry)
|
||||
self._attr_unique_id = f"{self._base_unique_id}-error-status"
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ ID_TYPE = BigInteger().with_variant(sqlite.INTEGER, "sqlite")
|
||||
# For MariaDB and MySQL we can use an unsigned integer type since it will fit 2**32
|
||||
# for sqlite and postgresql we use a bigint
|
||||
UINT_32_TYPE = BigInteger().with_variant(
|
||||
mysql.INTEGER(unsigned=True), # type: ignore[no-untyped-call]
|
||||
mysql.INTEGER(unsigned=True),
|
||||
"mysql",
|
||||
"mariadb",
|
||||
)
|
||||
@@ -206,12 +206,12 @@ JSONB_VARIANT_CAST = Text().with_variant(
|
||||
)
|
||||
DATETIME_TYPE = (
|
||||
DateTime(timezone=True)
|
||||
.with_variant(mysql.DATETIME(timezone=True, fsp=6), "mysql", "mariadb") # type: ignore[no-untyped-call]
|
||||
.with_variant(mysql.DATETIME(timezone=True, fsp=6), "mysql", "mariadb")
|
||||
.with_variant(FAST_PYSQLITE_DATETIME(), "sqlite") # type: ignore[no-untyped-call]
|
||||
)
|
||||
DOUBLE_TYPE = (
|
||||
Float()
|
||||
.with_variant(mysql.DOUBLE(asdecimal=False), "mysql", "mariadb") # type: ignore[no-untyped-call]
|
||||
.with_variant(mysql.DOUBLE(asdecimal=False), "mysql", "mariadb")
|
||||
.with_variant(oracle.DOUBLE_PRECISION(), "oracle")
|
||||
.with_variant(postgresql.DOUBLE_PRECISION(), "postgresql")
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"SQLAlchemy==2.0.41",
|
||||
"SQLAlchemy==2.0.49",
|
||||
"fnv-hash-fast==2.0.0",
|
||||
"psutil-home-assistant==0.0.1"
|
||||
]
|
||||
|
||||
@@ -447,10 +447,10 @@ def setup_connection_for_dialect(
|
||||
slow_dependent_subquery = False
|
||||
if dialect_name == SupportedDialect.SQLITE:
|
||||
if first_connection:
|
||||
old_isolation = dbapi_connection.isolation_level # type: ignore[attr-defined]
|
||||
dbapi_connection.isolation_level = None # type: ignore[attr-defined]
|
||||
old_isolation = dbapi_connection.isolation_level
|
||||
dbapi_connection.isolation_level = None
|
||||
execute_on_connection(dbapi_connection, "PRAGMA journal_mode=WAL")
|
||||
dbapi_connection.isolation_level = old_isolation # type: ignore[attr-defined]
|
||||
dbapi_connection.isolation_level = old_isolation
|
||||
# WAL mode only needs to be setup once
|
||||
# instead of every time we open the sqlite connection
|
||||
# as its persistent and isn't free to call every time.
|
||||
|
||||
@@ -4,27 +4,40 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Coroutine
|
||||
from copy import deepcopy
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.rest import RESOURCE_SCHEMA, create_rest_data_from_config
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.components.sensor import CONF_STATE_CLASS, DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.const import (
|
||||
CONF_ATTRIBUTE,
|
||||
CONF_AUTHENTICATION,
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_HEADERS,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_TIMEOUT,
|
||||
CONF_UNIQUE_ID,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_USERNAME,
|
||||
CONF_VALUE_TEMPLATE,
|
||||
CONF_VERIFY_SSL,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
discovery,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.trigger_template_entity import (
|
||||
CONF_AVAILABILITY,
|
||||
TEMPLATE_SENSOR_BASE_SCHEMA,
|
||||
@@ -32,11 +45,22 @@ from homeassistant.helpers.trigger_template_entity import (
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_INDEX, CONF_SELECT, DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS
|
||||
from .const import (
|
||||
CONF_ADVANCED,
|
||||
CONF_AUTH,
|
||||
CONF_ENCODING,
|
||||
CONF_INDEX,
|
||||
CONF_SELECT,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import ScrapeCoordinator
|
||||
|
||||
type ScrapeConfigEntry = ConfigEntry[ScrapeCoordinator]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SENSOR_SCHEMA = vol.Schema(
|
||||
{
|
||||
**TEMPLATE_SENSOR_BASE_SCHEMA.schema,
|
||||
@@ -103,7 +127,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bool:
|
||||
"""Set up Scrape from a config entry."""
|
||||
|
||||
rest_config: dict[str, Any] = COMBINED_SCHEMA(dict(entry.options))
|
||||
config: dict[str, Any] = dict(entry.options)
|
||||
# Config flow uses sections but the COMBINED SCHEMA does not
|
||||
# so we need to flatten the config here
|
||||
config.update(config.pop(CONF_ADVANCED, {}))
|
||||
config.update(config.pop(CONF_AUTH, {}))
|
||||
|
||||
rest_config: dict[str, Any] = COMBINED_SCHEMA(dict(config))
|
||||
rest = create_rest_data_from_config(hass, rest_config)
|
||||
|
||||
coordinator = ScrapeCoordinator(
|
||||
@@ -117,17 +147,159 @@ async def async_setup_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bo
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
|
||||
if entry.version > 2:
|
||||
# Don't migrate from future version
|
||||
return False
|
||||
|
||||
if entry.version == 1:
|
||||
old_to_new_sensor_id = {}
|
||||
for sensor_config in entry.options[SENSOR_DOMAIN]:
|
||||
# Create a new sub config entry per sensor
|
||||
title = sensor_config[CONF_NAME]
|
||||
old_unique_id = sensor_config[CONF_UNIQUE_ID]
|
||||
subentry_config = {
|
||||
CONF_INDEX: sensor_config[CONF_INDEX],
|
||||
CONF_SELECT: sensor_config[CONF_SELECT],
|
||||
CONF_ADVANCED: {},
|
||||
}
|
||||
|
||||
for sensor_advanced_key in (
|
||||
CONF_ATTRIBUTE,
|
||||
CONF_VALUE_TEMPLATE,
|
||||
CONF_AVAILABILITY,
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_STATE_CLASS,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
):
|
||||
if sensor_advanced_key not in sensor_config:
|
||||
continue
|
||||
subentry_config[CONF_ADVANCED][sensor_advanced_key] = sensor_config[
|
||||
sensor_advanced_key
|
||||
]
|
||||
|
||||
new_sub_entry = ConfigSubentry(
|
||||
data=MappingProxyType(subentry_config),
|
||||
subentry_type="entity",
|
||||
title=title,
|
||||
unique_id=None,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Migrating sensor %s with unique id %s to sub config entry id %s, old data %s, new data %s",
|
||||
title,
|
||||
old_unique_id,
|
||||
new_sub_entry.subentry_id,
|
||||
sensor_config,
|
||||
subentry_config,
|
||||
)
|
||||
old_to_new_sensor_id[old_unique_id] = new_sub_entry.subentry_id
|
||||
hass.config_entries.async_add_subentry(entry, new_sub_entry)
|
||||
|
||||
# Use the new sub config entry id as the unique id for the sensor entity
|
||||
entity_reg = er.async_get(hass)
|
||||
entities = er.async_entries_for_config_entry(entity_reg, entry.entry_id)
|
||||
for entity in entities:
|
||||
if (old_unique_id := entity.unique_id) in old_to_new_sensor_id:
|
||||
new_unique_id = old_to_new_sensor_id[old_unique_id]
|
||||
_LOGGER.debug(
|
||||
"Migrating entity %s with unique id %s to new unique id %s",
|
||||
entity.entity_id,
|
||||
entity.unique_id,
|
||||
new_unique_id,
|
||||
)
|
||||
entity_reg.async_update_entity(
|
||||
entity.entity_id,
|
||||
config_entry_id=entry.entry_id,
|
||||
config_subentry_id=new_unique_id,
|
||||
new_unique_id=new_unique_id,
|
||||
)
|
||||
|
||||
# Use the new sub config entry id as the identifier for the sensor device
|
||||
device_reg = dr.async_get(hass)
|
||||
devices = dr.async_entries_for_config_entry(device_reg, entry.entry_id)
|
||||
for device in devices:
|
||||
for domain, identifier in device.identifiers:
|
||||
if domain != DOMAIN or identifier not in old_to_new_sensor_id:
|
||||
continue
|
||||
|
||||
subentry_id = old_to_new_sensor_id[identifier]
|
||||
new_identifiers = deepcopy(device.identifiers)
|
||||
new_identifiers.remove((domain, identifier))
|
||||
new_identifiers.add((domain, old_to_new_sensor_id[identifier]))
|
||||
_LOGGER.debug(
|
||||
"Migrating device %s with identifiers %s to new identifiers %s",
|
||||
device.id,
|
||||
device.identifiers,
|
||||
new_identifiers,
|
||||
)
|
||||
device_reg.async_update_device(
|
||||
device.id,
|
||||
add_config_entry_id=entry.entry_id,
|
||||
add_config_subentry_id=subentry_id,
|
||||
new_identifiers=new_identifiers,
|
||||
)
|
||||
|
||||
# Removing None from the list of subentries if existing
|
||||
# as the device should only belong to the subentry
|
||||
# and not the main config entry
|
||||
device_reg.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
remove_config_subentry_id=None,
|
||||
)
|
||||
|
||||
# Update the resource config
|
||||
new_config_entry_data = dict(entry.options)
|
||||
new_config_entry_data[CONF_AUTH] = {}
|
||||
new_config_entry_data[CONF_ADVANCED] = {}
|
||||
new_config_entry_data.pop(SENSOR_DOMAIN, None)
|
||||
for resource_advanced_key in (
|
||||
CONF_HEADERS,
|
||||
CONF_VERIFY_SSL,
|
||||
CONF_TIMEOUT,
|
||||
CONF_ENCODING,
|
||||
):
|
||||
if resource_advanced_key in new_config_entry_data:
|
||||
new_config_entry_data[CONF_ADVANCED][resource_advanced_key] = (
|
||||
new_config_entry_data.pop(resource_advanced_key)
|
||||
)
|
||||
for resource_auth_key in (CONF_AUTHENTICATION, CONF_USERNAME, CONF_PASSWORD):
|
||||
if resource_auth_key in new_config_entry_data:
|
||||
new_config_entry_data[CONF_AUTH][resource_auth_key] = (
|
||||
new_config_entry_data.pop(resource_auth_key)
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migrating config entry %s from version 1 to version 2 with data %s",
|
||||
entry.entry_id,
|
||||
new_config_entry_data,
|
||||
)
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, version=2, options=new_config_entry_data
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bool:
|
||||
"""Unload Scrape config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def update_listener(hass: HomeAssistant, entry: ScrapeConfigEntry) -> None:
|
||||
"""Handle config entry update."""
|
||||
hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry
|
||||
hass: HomeAssistant, entry: ConfigEntry, device: dr.DeviceEntry
|
||||
) -> bool:
|
||||
"""Remove Scrape config entry from a device."""
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
import uuid
|
||||
from copy import deepcopy
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.rest import create_rest_data_from_config
|
||||
from homeassistant.components.rest.data import ( # pylint: disable=hass-component-root-import
|
||||
DEFAULT_TIMEOUT,
|
||||
@@ -18,10 +19,17 @@ from homeassistant.components.rest.schema import ( # pylint: disable=hass-compo
|
||||
)
|
||||
from homeassistant.components.sensor import (
|
||||
CONF_STATE_CLASS,
|
||||
DOMAIN as SENSOR_DOMAIN,
|
||||
SensorDeviceClass,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
ConfigSubentryFlow,
|
||||
OptionsFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_ATTRIBUTE,
|
||||
CONF_AUTHENTICATION,
|
||||
@@ -33,7 +41,6 @@ from homeassistant.const import (
|
||||
CONF_PAYLOAD,
|
||||
CONF_RESOURCE,
|
||||
CONF_TIMEOUT,
|
||||
CONF_UNIQUE_ID,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_USERNAME,
|
||||
CONF_VALUE_TEMPLATE,
|
||||
@@ -42,15 +49,7 @@ from homeassistant.const import (
|
||||
HTTP_DIGEST_AUTHENTICATION,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import async_get_hass
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaCommonFlowHandler,
|
||||
SchemaConfigFlowHandler,
|
||||
SchemaFlowError,
|
||||
SchemaFlowFormStep,
|
||||
SchemaFlowMenuStep,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.selector import (
|
||||
BooleanSelector,
|
||||
NumberSelector,
|
||||
@@ -69,6 +68,8 @@ from homeassistant.helpers.trigger_template_entity import CONF_AVAILABILITY
|
||||
|
||||
from . import COMBINED_SCHEMA
|
||||
from .const import (
|
||||
CONF_ADVANCED,
|
||||
CONF_AUTH,
|
||||
CONF_ENCODING,
|
||||
CONF_INDEX,
|
||||
CONF_SELECT,
|
||||
@@ -78,243 +79,212 @@ from .const import (
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
RESOURCE_SETUP = {
|
||||
vol.Required(CONF_RESOURCE): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.URL)
|
||||
),
|
||||
vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): SelectSelector(
|
||||
SelectSelectorConfig(options=METHODS, mode=SelectSelectorMode.DROPDOWN)
|
||||
),
|
||||
vol.Optional(CONF_PAYLOAD): ObjectSelector(),
|
||||
vol.Optional(CONF_AUTHENTICATION): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_USERNAME): TextSelector(),
|
||||
vol.Optional(CONF_PASSWORD): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Optional(CONF_HEADERS): ObjectSelector(),
|
||||
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): BooleanSelector(),
|
||||
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): NumberSelector(
|
||||
NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
|
||||
),
|
||||
vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(),
|
||||
}
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SENSOR_SETUP = {
|
||||
vol.Required(CONF_SELECT): TextSelector(),
|
||||
vol.Optional(CONF_INDEX, default=0): NumberSelector(
|
||||
NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
|
||||
),
|
||||
vol.Optional(CONF_ATTRIBUTE): TextSelector(),
|
||||
vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(),
|
||||
vol.Optional(CONF_AVAILABILITY): TemplateSelector(),
|
||||
vol.Optional(CONF_DEVICE_CLASS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
cls.value for cls in SensorDeviceClass if cls != SensorDeviceClass.ENUM
|
||||
],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key="device_class",
|
||||
sort=True,
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_STATE_CLASS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[cls.value for cls in SensorStateClass],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key="state_class",
|
||||
sort=True,
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[cls.value for cls in UnitOfTemperature],
|
||||
custom_value=True,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key="unit_of_measurement",
|
||||
sort=True,
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def validate_rest_setup(
|
||||
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Validate rest setup."""
|
||||
hass = async_get_hass()
|
||||
rest_config: dict[str, Any] = COMBINED_SCHEMA(user_input)
|
||||
try:
|
||||
rest = create_rest_data_from_config(hass, rest_config)
|
||||
await rest.async_update()
|
||||
except Exception as err:
|
||||
raise SchemaFlowError("resource_error") from err
|
||||
if rest.data is None:
|
||||
raise SchemaFlowError("resource_error")
|
||||
return user_input
|
||||
|
||||
|
||||
async def validate_sensor_setup(
|
||||
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Validate sensor input."""
|
||||
user_input[CONF_INDEX] = int(user_input[CONF_INDEX])
|
||||
user_input[CONF_UNIQUE_ID] = str(uuid.uuid1())
|
||||
|
||||
# Standard behavior is to merge the result with the options.
|
||||
# In this case, we want to add a sub-item so we update the options directly.
|
||||
sensors: list[dict[str, Any]] = handler.options.setdefault(SENSOR_DOMAIN, [])
|
||||
sensors.append(user_input)
|
||||
return {}
|
||||
|
||||
|
||||
async def validate_select_sensor(
|
||||
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Store sensor index in flow state."""
|
||||
handler.flow_state["_idx"] = int(user_input[CONF_INDEX])
|
||||
return {}
|
||||
|
||||
|
||||
async def get_select_sensor_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
|
||||
"""Return schema for selecting a sensor."""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_INDEX): vol.In(
|
||||
{
|
||||
str(index): config[CONF_NAME]
|
||||
for index, config in enumerate(handler.options[SENSOR_DOMAIN])
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def get_edit_sensor_suggested_values(
|
||||
handler: SchemaCommonFlowHandler,
|
||||
) -> dict[str, Any]:
|
||||
"""Return suggested values for sensor editing."""
|
||||
idx: int = handler.flow_state["_idx"]
|
||||
return dict(handler.options[SENSOR_DOMAIN][idx])
|
||||
|
||||
|
||||
async def validate_sensor_edit(
|
||||
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Update edited sensor."""
|
||||
user_input[CONF_INDEX] = int(user_input[CONF_INDEX])
|
||||
|
||||
# Standard behavior is to merge the result with the options.
|
||||
# In this case, we want to add a sub-item so we update the options directly,
|
||||
# including popping omitted optional schema items.
|
||||
idx: int = handler.flow_state["_idx"]
|
||||
handler.options[SENSOR_DOMAIN][idx].update(user_input)
|
||||
for key in DATA_SCHEMA_EDIT_SENSOR.schema:
|
||||
if isinstance(key, vol.Optional) and key not in user_input:
|
||||
# Key not present, delete keys old value (if present) too
|
||||
handler.options[SENSOR_DOMAIN][idx].pop(key, None)
|
||||
return {}
|
||||
|
||||
|
||||
async def get_remove_sensor_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
|
||||
"""Return schema for sensor removal."""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_INDEX): cv.multi_select(
|
||||
{
|
||||
str(index): config[CONF_NAME]
|
||||
for index, config in enumerate(handler.options[SENSOR_DOMAIN])
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def validate_remove_sensor(
|
||||
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Validate remove sensor."""
|
||||
removed_indexes: set[str] = set(user_input[CONF_INDEX])
|
||||
|
||||
# Standard behavior is to merge the result with the options.
|
||||
# In this case, we want to remove sub-items so we update the options directly.
|
||||
entity_registry = er.async_get(handler.parent_handler.hass)
|
||||
sensors: list[dict[str, Any]] = []
|
||||
sensor: dict[str, Any]
|
||||
for index, sensor in enumerate(handler.options[SENSOR_DOMAIN]):
|
||||
if str(index) not in removed_indexes:
|
||||
sensors.append(sensor)
|
||||
elif entity_id := entity_registry.async_get_entity_id(
|
||||
SENSOR_DOMAIN, DOMAIN, sensor[CONF_UNIQUE_ID]
|
||||
):
|
||||
entity_registry.async_remove(entity_id)
|
||||
handler.options[SENSOR_DOMAIN] = sensors
|
||||
return {}
|
||||
|
||||
|
||||
DATA_SCHEMA_RESOURCE = vol.Schema(RESOURCE_SETUP)
|
||||
DATA_SCHEMA_EDIT_SENSOR = vol.Schema(SENSOR_SETUP)
|
||||
DATA_SCHEMA_SENSOR = vol.Schema(
|
||||
RESOURCE_SETUP = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): TextSelector(),
|
||||
**SENSOR_SETUP,
|
||||
vol.Required(CONF_RESOURCE): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.URL)
|
||||
),
|
||||
vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): SelectSelector(
|
||||
SelectSelectorConfig(options=METHODS, mode=SelectSelectorMode.DROPDOWN)
|
||||
),
|
||||
vol.Optional(CONF_PAYLOAD): ObjectSelector(),
|
||||
vol.Required(CONF_AUTH): data_entry_flow.section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_AUTHENTICATION): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
HTTP_BASIC_AUTHENTICATION,
|
||||
HTTP_DIGEST_AUTHENTICATION,
|
||||
],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_USERNAME): TextSelector(
|
||||
TextSelectorConfig(
|
||||
type=TextSelectorType.TEXT, autocomplete="username"
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_PASSWORD): TextSelector(
|
||||
TextSelectorConfig(
|
||||
type=TextSelectorType.PASSWORD,
|
||||
autocomplete="current-password",
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
data_entry_flow.SectionConfig(collapsed=True),
|
||||
),
|
||||
vol.Required(CONF_ADVANCED): data_entry_flow.section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_HEADERS): ObjectSelector(),
|
||||
vol.Optional(
|
||||
CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL
|
||||
): BooleanSelector(),
|
||||
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): NumberSelector(
|
||||
NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_ENCODING, default=DEFAULT_ENCODING
|
||||
): TextSelector(),
|
||||
}
|
||||
),
|
||||
data_entry_flow.SectionConfig(collapsed=True),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
CONFIG_FLOW = {
|
||||
"user": SchemaFlowFormStep(
|
||||
schema=DATA_SCHEMA_RESOURCE,
|
||||
next_step="sensor",
|
||||
validate_user_input=validate_rest_setup,
|
||||
),
|
||||
"sensor": SchemaFlowFormStep(
|
||||
schema=DATA_SCHEMA_SENSOR,
|
||||
validate_user_input=validate_sensor_setup,
|
||||
),
|
||||
}
|
||||
OPTIONS_FLOW = {
|
||||
"init": SchemaFlowMenuStep(
|
||||
["resource", "add_sensor", "select_edit_sensor", "remove_sensor"]
|
||||
),
|
||||
"resource": SchemaFlowFormStep(
|
||||
DATA_SCHEMA_RESOURCE,
|
||||
validate_user_input=validate_rest_setup,
|
||||
),
|
||||
"add_sensor": SchemaFlowFormStep(
|
||||
DATA_SCHEMA_SENSOR,
|
||||
suggested_values=None,
|
||||
validate_user_input=validate_sensor_setup,
|
||||
),
|
||||
"select_edit_sensor": SchemaFlowFormStep(
|
||||
get_select_sensor_schema,
|
||||
suggested_values=None,
|
||||
validate_user_input=validate_select_sensor,
|
||||
next_step="edit_sensor",
|
||||
),
|
||||
"edit_sensor": SchemaFlowFormStep(
|
||||
DATA_SCHEMA_EDIT_SENSOR,
|
||||
suggested_values=get_edit_sensor_suggested_values,
|
||||
validate_user_input=validate_sensor_edit,
|
||||
),
|
||||
"remove_sensor": SchemaFlowFormStep(
|
||||
get_remove_sensor_schema,
|
||||
suggested_values=None,
|
||||
validate_user_input=validate_remove_sensor,
|
||||
),
|
||||
}
|
||||
SENSOR_SETUP = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): TextSelector(),
|
||||
vol.Required(CONF_SELECT): TextSelector(),
|
||||
vol.Optional(CONF_INDEX, default=0): vol.All(
|
||||
NumberSelector(
|
||||
NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
|
||||
),
|
||||
vol.Coerce(int),
|
||||
),
|
||||
vol.Required(CONF_ADVANCED): data_entry_flow.section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_ATTRIBUTE): TextSelector(),
|
||||
vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(),
|
||||
vol.Optional(CONF_AVAILABILITY): TemplateSelector(),
|
||||
vol.Optional(CONF_DEVICE_CLASS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
cls.value
|
||||
for cls in SensorDeviceClass
|
||||
if cls != SensorDeviceClass.ENUM
|
||||
],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key="device_class",
|
||||
sort=True,
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_STATE_CLASS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[cls.value for cls in SensorStateClass],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key="state_class",
|
||||
sort=True,
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[cls.value for cls in UnitOfTemperature],
|
||||
custom_value=True,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key="unit_of_measurement",
|
||||
sort=True,
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
data_entry_flow.SectionConfig(collapsed=True),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ScrapeConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
||||
"""Handle a config flow for Scrape."""
|
||||
async def validate_rest_setup(
|
||||
hass: HomeAssistant, user_input: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Validate rest setup."""
|
||||
config = deepcopy(user_input)
|
||||
config.update(config.pop(CONF_ADVANCED, {}))
|
||||
config.update(config.pop(CONF_AUTH, {}))
|
||||
rest_config: dict[str, Any] = COMBINED_SCHEMA(config)
|
||||
try:
|
||||
rest = create_rest_data_from_config(hass, rest_config)
|
||||
await rest.async_update()
|
||||
except Exception:
|
||||
_LOGGER.exception("Error when getting resource %s", config[CONF_RESOURCE])
|
||||
return {"base": "resource_error"}
|
||||
if rest.data is None:
|
||||
return {"base": "no_data"}
|
||||
return {}
|
||||
|
||||
config_flow = CONFIG_FLOW
|
||||
options_flow = OPTIONS_FLOW
|
||||
options_flow_reloads = True
|
||||
|
||||
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
|
||||
"""Return config entry title."""
|
||||
return cast(str, options[CONF_RESOURCE])
|
||||
class ScrapeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Scrape configuration flow."""
|
||||
|
||||
VERSION = 2
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> ScrapeOptionFlow:
|
||||
"""Get the options flow for this handler."""
|
||||
return ScrapeOptionFlow()
|
||||
|
||||
@classmethod
|
||||
@callback
|
||||
def async_get_supported_subentry_types(
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this handler."""
|
||||
return {"entity": ScrapeSubentryFlowHandler}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""User flow to create the main config entry."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
errors = await validate_rest_setup(self.hass, user_input)
|
||||
title = user_input[CONF_RESOURCE]
|
||||
if not errors:
|
||||
return self.async_create_entry(data={}, options=user_input, title=title)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
RESOURCE_SETUP, user_input or {}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class ScrapeOptionFlow(OptionsFlow):
|
||||
"""Scrape Options flow."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage Scrape options."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
errors = await validate_rest_setup(self.hass, user_input)
|
||||
if not errors:
|
||||
return self.async_create_entry(data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
RESOURCE_SETUP,
|
||||
user_input or self.config_entry.options,
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class ScrapeSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle subentry flow."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""User flow to create a sensor subentry."""
|
||||
if user_input is not None:
|
||||
title = user_input.pop("name")
|
||||
return self.async_create_entry(data=user_input, title=title)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
SENSOR_SETUP, user_input or {}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -14,6 +14,8 @@ DEFAULT_SCAN_INTERVAL = timedelta(minutes=10)
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
CONF_ADVANCED = "advanced"
|
||||
CONF_AUTH = "auth"
|
||||
CONF_ENCODING = "encoding"
|
||||
CONF_SELECT = "select"
|
||||
CONF_INDEX = "index"
|
||||
|
||||
21
homeassistant/components/scrape/icons.json
Normal file
21
homeassistant/components/scrape/icons.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"sections": {
|
||||
"advanced": "mdi:cog",
|
||||
"auth": "mdi:lock"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"sections": {
|
||||
"advanced": "mdi:cog"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,9 +46,10 @@ TRIGGER_ENTITY_OPTIONS = (
|
||||
CONF_AVAILABILITY,
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_ICON,
|
||||
CONF_NAME,
|
||||
CONF_PICTURE,
|
||||
CONF_UNIQUE_ID,
|
||||
CONF_STATE_CLASS,
|
||||
CONF_UNIQUE_ID,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
)
|
||||
|
||||
@@ -70,7 +71,7 @@ async def async_setup_platform(
|
||||
|
||||
entities: list[ScrapeSensor] = []
|
||||
for sensor_config in sensors_config:
|
||||
trigger_entity_config = {CONF_NAME: sensor_config[CONF_NAME]}
|
||||
trigger_entity_config = {}
|
||||
for key in TRIGGER_ENTITY_OPTIONS:
|
||||
if key not in sensor_config:
|
||||
continue
|
||||
@@ -98,23 +99,24 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Scrape sensor entry."""
|
||||
entities: list = []
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
config = dict(entry.options)
|
||||
for sensor in config["sensor"]:
|
||||
for subentry in entry.subentries.values():
|
||||
sensor = dict(subentry.data)
|
||||
sensor.update(sensor.pop("advanced", {}))
|
||||
sensor[CONF_UNIQUE_ID] = subentry.subentry_id
|
||||
sensor[CONF_NAME] = subentry.title
|
||||
|
||||
sensor_config: ConfigType = vol.Schema(
|
||||
TEMPLATE_SENSOR_BASE_SCHEMA.schema, extra=vol.ALLOW_EXTRA
|
||||
)(sensor)
|
||||
|
||||
name: str = sensor_config[CONF_NAME]
|
||||
value_string: str | None = sensor_config.get(CONF_VALUE_TEMPLATE)
|
||||
|
||||
value_template: ValueTemplate | None = (
|
||||
ValueTemplate(value_string, hass) if value_string is not None else None
|
||||
)
|
||||
|
||||
trigger_entity_config: dict[str, str | Template | None] = {CONF_NAME: name}
|
||||
trigger_entity_config: dict[str, str | Template | None] = {}
|
||||
for key in TRIGGER_ENTITY_OPTIONS:
|
||||
if key not in sensor_config:
|
||||
continue
|
||||
@@ -123,21 +125,22 @@ async def async_setup_entry(
|
||||
continue
|
||||
trigger_entity_config[key] = sensor_config[key]
|
||||
|
||||
entities.append(
|
||||
ScrapeSensor(
|
||||
hass,
|
||||
coordinator,
|
||||
trigger_entity_config,
|
||||
sensor_config[CONF_SELECT],
|
||||
sensor_config.get(CONF_ATTRIBUTE),
|
||||
sensor_config[CONF_INDEX],
|
||||
value_template,
|
||||
False,
|
||||
)
|
||||
async_add_entities(
|
||||
[
|
||||
ScrapeSensor(
|
||||
hass,
|
||||
coordinator,
|
||||
trigger_entity_config,
|
||||
sensor_config[CONF_SELECT],
|
||||
sensor_config.get(CONF_ATTRIBUTE),
|
||||
sensor_config[CONF_INDEX],
|
||||
value_template,
|
||||
False,
|
||||
)
|
||||
],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEntity):
|
||||
"""Representation of a web scrape sensor."""
|
||||
|
||||
@@ -4,134 +4,140 @@
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
},
|
||||
"error": {
|
||||
"resource_error": "Could not update rest data. Verify your configuration"
|
||||
"no_data": "REST data is empty. Verify your configuration",
|
||||
"resource_error": "Could not update REST data. Verify your configuration"
|
||||
},
|
||||
"step": {
|
||||
"sensor": {
|
||||
"data": {
|
||||
"attribute": "Attribute",
|
||||
"availability": "Availability template",
|
||||
"device_class": "Device class",
|
||||
"index": "Index",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"select": "Select",
|
||||
"state_class": "State class",
|
||||
"unit_of_measurement": "Unit of measurement",
|
||||
"value_template": "Value template"
|
||||
},
|
||||
"data_description": {
|
||||
"attribute": "Get value of an attribute on the selected tag.",
|
||||
"availability": "Defines a template to get the availability of the sensor.",
|
||||
"device_class": "The type/class of the sensor to set the icon in the frontend.",
|
||||
"index": "Defines which of the elements returned by the CSS selector to use.",
|
||||
"select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details.",
|
||||
"state_class": "The state_class of the sensor.",
|
||||
"unit_of_measurement": "Choose unit of measurement or create your own.",
|
||||
"value_template": "Defines a template to get the state of the sensor."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"authentication": "Select authentication method",
|
||||
"encoding": "Character encoding",
|
||||
"headers": "Headers",
|
||||
"method": "Method",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"payload": "Payload",
|
||||
"resource": "Resource",
|
||||
"timeout": "Timeout",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
"resource": "Resource"
|
||||
},
|
||||
"data_description": {
|
||||
"authentication": "Type of the HTTP authentication. Either basic or digest.",
|
||||
"encoding": "Character encoding to use. Defaults to UTF-8.",
|
||||
"headers": "Headers to use for the web request.",
|
||||
"payload": "Payload to use when method is POST.",
|
||||
"resource": "The URL to the website that contains the value.",
|
||||
"timeout": "Timeout for connection to website.",
|
||||
"verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed."
|
||||
"resource": "The URL to the website that contains the value."
|
||||
},
|
||||
"sections": {
|
||||
"advanced": {
|
||||
"data": {
|
||||
"encoding": "Character encoding",
|
||||
"headers": "Headers",
|
||||
"timeout": "Timeout",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"encoding": "Character encoding to use. Defaults to UTF-8.",
|
||||
"headers": "Headers to use for the web request.",
|
||||
"timeout": "Timeout for connection to website.",
|
||||
"verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed."
|
||||
},
|
||||
"description": "Provide additional advanced settings for the resource.",
|
||||
"name": "Advanced settings"
|
||||
},
|
||||
"auth": {
|
||||
"data": {
|
||||
"authentication": "Select authentication method",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"authentication": "Type of the HTTP authentication. Either basic or digest."
|
||||
},
|
||||
"description": "Provide authentication details to access the resource.",
|
||||
"name": "Authentication settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"entity": {
|
||||
"entry_type": "Sensor",
|
||||
"initiate_flow": {
|
||||
"user": "Add sensor"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"index": "Index",
|
||||
"select": "Select"
|
||||
},
|
||||
"data_description": {
|
||||
"index": "Defines which of the elements returned by the CSS selector to use.",
|
||||
"select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details."
|
||||
},
|
||||
"sections": {
|
||||
"advanced": {
|
||||
"data": {
|
||||
"attribute": "Attribute",
|
||||
"availability": "Availability template",
|
||||
"device_class": "Device class",
|
||||
"state_class": "State class",
|
||||
"unit_of_measurement": "Unit of measurement",
|
||||
"value_template": "Value template"
|
||||
},
|
||||
"data_description": {
|
||||
"attribute": "Get value of an attribute on the selected tag.",
|
||||
"availability": "Defines a template to get the availability of the sensor.",
|
||||
"device_class": "The type/class of the sensor to set the icon in the frontend.",
|
||||
"state_class": "The state_class of the sensor.",
|
||||
"unit_of_measurement": "Choose unit of measurement or create your own.",
|
||||
"value_template": "Defines a template to get the state of the sensor."
|
||||
},
|
||||
"description": "Provide additional advanced settings for the sensor.",
|
||||
"name": "Advanced settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"error": {
|
||||
"no_data": "[%key:component::scrape::config::error::no_data%]",
|
||||
"resource_error": "[%key:component::scrape::config::error::resource_error%]"
|
||||
},
|
||||
"step": {
|
||||
"add_sensor": {
|
||||
"data": {
|
||||
"attribute": "[%key:component::scrape::config::step::sensor::data::attribute%]",
|
||||
"availability": "[%key:component::scrape::config::step::sensor::data::availability%]",
|
||||
"device_class": "[%key:component::scrape::config::step::sensor::data::device_class%]",
|
||||
"index": "[%key:component::scrape::config::step::sensor::data::index%]",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"select": "[%key:component::scrape::config::step::sensor::data::select%]",
|
||||
"state_class": "[%key:component::scrape::config::step::sensor::data::state_class%]",
|
||||
"unit_of_measurement": "[%key:component::scrape::config::step::sensor::data::unit_of_measurement%]",
|
||||
"value_template": "[%key:component::scrape::config::step::sensor::data::value_template%]"
|
||||
},
|
||||
"data_description": {
|
||||
"attribute": "[%key:component::scrape::config::step::sensor::data_description::attribute%]",
|
||||
"availability": "[%key:component::scrape::config::step::sensor::data_description::availability%]",
|
||||
"device_class": "[%key:component::scrape::config::step::sensor::data_description::device_class%]",
|
||||
"index": "[%key:component::scrape::config::step::sensor::data_description::index%]",
|
||||
"select": "[%key:component::scrape::config::step::sensor::data_description::select%]",
|
||||
"state_class": "[%key:component::scrape::config::step::sensor::data_description::state_class%]",
|
||||
"unit_of_measurement": "[%key:component::scrape::config::step::sensor::data_description::unit_of_measurement%]",
|
||||
"value_template": "[%key:component::scrape::config::step::sensor::data_description::value_template%]"
|
||||
}
|
||||
},
|
||||
"edit_sensor": {
|
||||
"data": {
|
||||
"attribute": "[%key:component::scrape::config::step::sensor::data::attribute%]",
|
||||
"availability": "[%key:component::scrape::config::step::sensor::data::availability%]",
|
||||
"device_class": "[%key:component::scrape::config::step::sensor::data::device_class%]",
|
||||
"index": "[%key:component::scrape::config::step::sensor::data::index%]",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"select": "[%key:component::scrape::config::step::sensor::data::select%]",
|
||||
"state_class": "[%key:component::scrape::config::step::sensor::data::state_class%]",
|
||||
"unit_of_measurement": "[%key:component::scrape::config::step::sensor::data::unit_of_measurement%]",
|
||||
"value_template": "[%key:component::scrape::config::step::sensor::data::value_template%]"
|
||||
},
|
||||
"data_description": {
|
||||
"attribute": "[%key:component::scrape::config::step::sensor::data_description::attribute%]",
|
||||
"availability": "[%key:component::scrape::config::step::sensor::data_description::availability%]",
|
||||
"device_class": "[%key:component::scrape::config::step::sensor::data_description::device_class%]",
|
||||
"index": "[%key:component::scrape::config::step::sensor::data_description::index%]",
|
||||
"select": "[%key:component::scrape::config::step::sensor::data_description::select%]",
|
||||
"state_class": "[%key:component::scrape::config::step::sensor::data_description::state_class%]",
|
||||
"unit_of_measurement": "[%key:component::scrape::config::step::sensor::data_description::unit_of_measurement%]",
|
||||
"value_template": "[%key:component::scrape::config::step::sensor::data_description::value_template%]"
|
||||
}
|
||||
},
|
||||
"init": {
|
||||
"menu_options": {
|
||||
"add_sensor": "Add sensor",
|
||||
"remove_sensor": "Remove sensor",
|
||||
"resource": "Configure resource",
|
||||
"select_edit_sensor": "Configure sensor"
|
||||
}
|
||||
},
|
||||
"resource": {
|
||||
"data": {
|
||||
"authentication": "[%key:component::scrape::config::step::user::data::authentication%]",
|
||||
"encoding": "[%key:component::scrape::config::step::user::data::encoding%]",
|
||||
"headers": "[%key:component::scrape::config::step::user::data::headers%]",
|
||||
"method": "[%key:component::scrape::config::step::user::data::method%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"payload": "[%key:component::scrape::config::step::user::data::payload%]",
|
||||
"resource": "[%key:component::scrape::config::step::user::data::resource%]",
|
||||
"timeout": "[%key:component::scrape::config::step::user::data::timeout%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
"resource": "[%key:component::scrape::config::step::user::data::resource%]"
|
||||
},
|
||||
"data_description": {
|
||||
"authentication": "[%key:component::scrape::config::step::user::data_description::authentication%]",
|
||||
"encoding": "[%key:component::scrape::config::step::user::data_description::encoding%]",
|
||||
"headers": "[%key:component::scrape::config::step::user::data_description::headers%]",
|
||||
"payload": "[%key:component::scrape::config::step::user::data_description::payload%]",
|
||||
"resource": "[%key:component::scrape::config::step::user::data_description::resource%]",
|
||||
"timeout": "[%key:component::scrape::config::step::user::data_description::timeout%]",
|
||||
"verify_ssl": "[%key:component::scrape::config::step::user::data_description::verify_ssl%]"
|
||||
"resource": "[%key:component::scrape::config::step::user::data_description::resource%]"
|
||||
},
|
||||
"sections": {
|
||||
"advanced": {
|
||||
"data": {
|
||||
"encoding": "[%key:component::scrape::config::step::user::sections::advanced::data::encoding%]",
|
||||
"headers": "[%key:component::scrape::config::step::user::sections::advanced::data::headers%]",
|
||||
"timeout": "[%key:component::scrape::config::step::user::sections::advanced::data::timeout%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"encoding": "[%key:component::scrape::config::step::user::sections::advanced::data_description::encoding%]",
|
||||
"headers": "[%key:component::scrape::config::step::user::sections::advanced::data_description::headers%]",
|
||||
"timeout": "[%key:component::scrape::config::step::user::sections::advanced::data_description::timeout%]",
|
||||
"verify_ssl": "[%key:component::scrape::config::step::user::sections::advanced::data_description::verify_ssl%]"
|
||||
},
|
||||
"description": "[%key:component::scrape::config::step::user::sections::advanced::description%]",
|
||||
"name": "[%key:component::scrape::config::step::user::sections::advanced::name%]"
|
||||
},
|
||||
"auth": {
|
||||
"data": {
|
||||
"authentication": "[%key:component::scrape::config::step::user::sections::auth::data::authentication%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"authentication": "[%key:component::scrape::config::step::user::sections::auth::data_description::authentication%]"
|
||||
},
|
||||
"description": "[%key:component::scrape::config::step::user::sections::auth::description%]",
|
||||
"name": "[%key:component::scrape::config::step::user::sections::auth::name%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -807,7 +807,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
ssid_options = [network["ssid"] for network in sorted_networks]
|
||||
|
||||
# Pre-select SSID if returning from failed provisioning attempt
|
||||
# Preselect SSID if returning from failed provisioning attempt
|
||||
suggested_values: dict[str, Any] = {}
|
||||
if self.selected_ssid:
|
||||
suggested_values[CONF_SSID] = self.selected_ssid
|
||||
@@ -1086,7 +1086,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle failed provisioning - allow retry."""
|
||||
if user_input is not None:
|
||||
# User wants to retry - keep selected_ssid so it's pre-selected
|
||||
# User wants to retry - keep selected_ssid so it's preselected
|
||||
self.wifi_networks = []
|
||||
return await self.async_step_wifi_scan()
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/sql",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["SQLAlchemy==2.0.41", "sqlparse==0.5.5"]
|
||||
"requirements": ["SQLAlchemy==2.0.49", "sqlparse==0.5.5"]
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ from homeassistant.components.recorder.statistics import (
|
||||
)
|
||||
from homeassistant.const import UnitOfEnergy
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.unit_conversion import EnergyConverter
|
||||
@@ -92,11 +91,40 @@ def _build_home_data(home: tibber.TibberHome) -> TibberHomeData:
|
||||
return result
|
||||
|
||||
|
||||
class TibberDataCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Handle Tibber data and insert statistics."""
|
||||
class TibberCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
|
||||
"""Base Tibber coordinator."""
|
||||
|
||||
config_entry: TibberConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: TibberConfigEntry,
|
||||
*,
|
||||
name: str,
|
||||
update_interval: timedelta,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=name,
|
||||
update_interval=update_interval,
|
||||
)
|
||||
self._runtime_data = config_entry.runtime_data
|
||||
|
||||
async def _async_get_client(self) -> tibber.Tibber:
|
||||
"""Get the Tibber client with error handling."""
|
||||
try:
|
||||
return await self._runtime_data.async_get_client(self.hass)
|
||||
except (ClientError, TimeoutError, tibber.exceptions.HttpExceptionError) as err:
|
||||
raise UpdateFailed(f"Unable to create Tibber client: {err}") from err
|
||||
|
||||
|
||||
class TibberDataCoordinator(TibberCoordinator[None]):
|
||||
"""Handle Tibber data and insert statistics."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -106,17 +134,14 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Initialize the data handler."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
config_entry,
|
||||
name=f"Tibber {tibber_connection.name}",
|
||||
update_interval=timedelta(minutes=20),
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Update data via API."""
|
||||
tibber_connection = await self.config_entry.runtime_data.async_get_client(
|
||||
self.hass
|
||||
)
|
||||
tibber_connection = await self._async_get_client()
|
||||
|
||||
try:
|
||||
await tibber_connection.fetch_consumption_data_active_homes()
|
||||
@@ -132,9 +157,7 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]):
|
||||
|
||||
async def _insert_statistics(self) -> None:
|
||||
"""Insert Tibber statistics."""
|
||||
tibber_connection = await self.config_entry.runtime_data.async_get_client(
|
||||
self.hass
|
||||
)
|
||||
tibber_connection = await self._async_get_client()
|
||||
for home in tibber_connection.get_homes():
|
||||
sensors: list[tuple[str, bool, str | None, str]] = []
|
||||
if home.hourly_consumption_data:
|
||||
@@ -254,11 +277,9 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]):
|
||||
async_add_external_statistics(self.hass, metadata, statistics)
|
||||
|
||||
|
||||
class TibberPriceCoordinator(DataUpdateCoordinator[dict[str, TibberHomeData]]):
|
||||
class TibberPriceCoordinator(TibberCoordinator[dict[str, TibberHomeData]]):
|
||||
"""Handle Tibber price data and insert statistics."""
|
||||
|
||||
config_entry: TibberConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -267,8 +288,7 @@ class TibberPriceCoordinator(DataUpdateCoordinator[dict[str, TibberHomeData]]):
|
||||
"""Initialize the price coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
config_entry,
|
||||
name=f"{DOMAIN} price",
|
||||
update_interval=timedelta(minutes=1),
|
||||
)
|
||||
@@ -290,9 +310,7 @@ class TibberPriceCoordinator(DataUpdateCoordinator[dict[str, TibberHomeData]]):
|
||||
|
||||
async def _async_update_data(self) -> dict[str, TibberHomeData]:
|
||||
"""Update data via API and return per-home data for sensors."""
|
||||
tibber_connection = await self.config_entry.runtime_data.async_get_client(
|
||||
self.hass
|
||||
)
|
||||
tibber_connection = await self._async_get_client()
|
||||
active_homes = tibber_connection.get_homes(only_active=True)
|
||||
|
||||
now = dt_util.now()
|
||||
@@ -347,11 +365,9 @@ class TibberPriceCoordinator(DataUpdateCoordinator[dict[str, TibberHomeData]]):
|
||||
return result
|
||||
|
||||
|
||||
class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]):
|
||||
class TibberDataAPICoordinator(TibberCoordinator[dict[str, TibberDevice]]):
|
||||
"""Fetch and cache Tibber Data API device capabilities."""
|
||||
|
||||
config_entry: TibberConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -360,12 +376,10 @@ class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]):
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
entry,
|
||||
name=f"{DOMAIN} Data API",
|
||||
update_interval=timedelta(minutes=1),
|
||||
config_entry=entry,
|
||||
)
|
||||
self._runtime_data = entry.runtime_data
|
||||
self.sensors_by_device: dict[str, dict[str, tibber.data_api.Sensor]] = {}
|
||||
|
||||
def _build_sensor_lookup(self, devices: dict[str, TibberDevice]) -> None:
|
||||
@@ -383,15 +397,6 @@ class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]):
|
||||
return device_sensors.get(sensor_id)
|
||||
return None
|
||||
|
||||
async def _async_get_client(self) -> tibber.Tibber:
|
||||
"""Get the Tibber client with error handling."""
|
||||
try:
|
||||
return await self._runtime_data.async_get_client(self.hass)
|
||||
except ConfigEntryAuthFailed:
|
||||
raise
|
||||
except (ClientError, TimeoutError, tibber.UserAgentMissingError) as err:
|
||||
raise UpdateFailed(f"Unable to create Tibber client: {err}") from err
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Initial load of Tibber Data API devices."""
|
||||
client = await self._async_get_client()
|
||||
|
||||
17
homeassistant/components/update/condition.py
Normal file
17
homeassistant/components/update/condition.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Provides conditions for updates."""
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.condition import Condition, make_entity_state_condition
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_available": make_entity_state_condition(DOMAIN, STATE_ON),
|
||||
"is_not_available": make_entity_state_condition(DOMAIN, STATE_OFF),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the update conditions."""
|
||||
return CONDITIONS
|
||||
17
homeassistant/components/update/conditions.yaml
Normal file
17
homeassistant/components/update/conditions.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
.condition_common: &condition_common
|
||||
target:
|
||||
entity:
|
||||
domain: update
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
|
||||
is_available: *condition_common
|
||||
is_not_available: *condition_common
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_available": {
|
||||
"condition": "mdi:package-up"
|
||||
},
|
||||
"is_not_available": {
|
||||
"condition": "mdi:package"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:package-up",
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_name": "Condition passes if",
|
||||
"trigger_behavior_name": "Trigger when"
|
||||
},
|
||||
"conditions": {
|
||||
"is_available": {
|
||||
"description": "Tests if one or more updates are available.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::update::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Update is available"
|
||||
},
|
||||
"is_not_available": {
|
||||
"description": "Tests if one or more updates are not available.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::update::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Update is not available"
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
"extra_fields": {
|
||||
"for": "[%key:common::device_automation::extra_fields::for%]"
|
||||
@@ -59,6 +80,12 @@
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
|
||||
@@ -315,7 +315,7 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
||||
|
||||
return await self.async_step_verify_radio()
|
||||
|
||||
# Pre-select the currently configured port
|
||||
# Preselect the currently configured port
|
||||
default_port: vol.Undefined | str = vol.UNDEFINED
|
||||
|
||||
if self._radio_mgr.device_path is not None:
|
||||
@@ -345,7 +345,7 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
||||
)
|
||||
return await self.async_step_manual_port_config()
|
||||
|
||||
# Pre-select the current radio type
|
||||
# Preselect the current radio type
|
||||
default: vol.Undefined | str = vol.UNDEFINED
|
||||
|
||||
if self._radio_mgr.radio_type is not None:
|
||||
|
||||
@@ -7,6 +7,7 @@ import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Coroutine, Iterable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
@@ -31,6 +32,7 @@ from homeassistant.const import (
|
||||
CONF_ENABLED,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_EVENT_DATA,
|
||||
CONF_FOR,
|
||||
CONF_ID,
|
||||
CONF_OPTIONS,
|
||||
CONF_PLATFORM,
|
||||
@@ -74,6 +76,7 @@ from .automation import (
|
||||
get_relative_description_key,
|
||||
move_options_fields_to_top_level,
|
||||
)
|
||||
from .event import async_track_same_state
|
||||
from .integration_platform import async_process_integration_platforms
|
||||
from .selector import (
|
||||
NumericThresholdMode,
|
||||
@@ -340,6 +343,7 @@ ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST = ENTITY_STATE_TRIGGER_SCHEMA.extend(
|
||||
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
|
||||
[BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY]
|
||||
),
|
||||
vol.Optional(CONF_FOR): cv.positive_time_period_dict,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -349,6 +353,9 @@ class EntityTriggerBase(Trigger):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_domain_specs: Mapping[str, DomainSpec]
|
||||
_excluded_states: Final[frozenset[str]] = frozenset(
|
||||
{STATE_UNAVAILABLE, STATE_UNKNOWN}
|
||||
)
|
||||
_schema: vol.Schema = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST
|
||||
|
||||
@override
|
||||
@@ -365,6 +372,7 @@ class EntityTriggerBase(Trigger):
|
||||
if TYPE_CHECKING:
|
||||
assert config.target is not None
|
||||
self._options = config.options or {}
|
||||
self._duration: timedelta | None = self._options.get(CONF_FOR)
|
||||
self._target = config.target
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
@@ -392,17 +400,16 @@ class EntityTriggerBase(Trigger):
|
||||
self.is_valid_state(state)
|
||||
for entity_id in entity_ids
|
||||
if (state := self._hass.states.get(entity_id)) is not None
|
||||
and state.state not in self._excluded_states
|
||||
)
|
||||
|
||||
def check_one_match(self, entity_ids: set[str]) -> bool:
|
||||
"""Check that only one entity state matches."""
|
||||
return (
|
||||
sum(
|
||||
self.is_valid_state(state)
|
||||
for entity_id in entity_ids
|
||||
if (state := self._hass.states.get(entity_id)) is not None
|
||||
)
|
||||
== 1
|
||||
def count_matches(self, entity_ids: set[str]) -> int:
|
||||
"""Count the number of entity states that match."""
|
||||
return sum(
|
||||
self.is_valid_state(state)
|
||||
for entity_id in entity_ids
|
||||
if (state := self._hass.states.get(entity_id)) is not None
|
||||
and state.state not in self._excluded_states
|
||||
)
|
||||
|
||||
@override
|
||||
@@ -411,7 +418,8 @@ class EntityTriggerBase(Trigger):
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
|
||||
behavior = self._options.get(ATTR_BEHAVIOR)
|
||||
behavior: str = self._options.get(ATTR_BEHAVIOR, BEHAVIOR_ANY)
|
||||
unsub_track_same: dict[str, Callable[[], None]] = {}
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
@@ -423,6 +431,30 @@ class EntityTriggerBase(Trigger):
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
def state_still_valid(
|
||||
_: str, from_state: State | None, to_state: State | None
|
||||
) -> bool:
|
||||
"""Check if the state is still valid during the duration wait.
|
||||
|
||||
Called by async_track_same_state on each state change to
|
||||
determine whether to cancel the timer.
|
||||
For behavior any, checks the individual entity's state.
|
||||
For behavior first/last, checks the combined state.
|
||||
"""
|
||||
if behavior == BEHAVIOR_LAST:
|
||||
return self.check_all_match(
|
||||
target_state_change_data.targeted_entity_ids
|
||||
)
|
||||
if behavior == BEHAVIOR_FIRST:
|
||||
return (
|
||||
self.count_matches(target_state_change_data.targeted_entity_ids)
|
||||
>= 1
|
||||
)
|
||||
# Behavior any: check the individual entity's state
|
||||
if not to_state:
|
||||
return False
|
||||
return self.is_valid_state(to_state)
|
||||
|
||||
if not from_state or not to_state:
|
||||
return
|
||||
|
||||
@@ -440,25 +472,65 @@ class EntityTriggerBase(Trigger):
|
||||
):
|
||||
return
|
||||
elif behavior == BEHAVIOR_FIRST:
|
||||
if not self.check_one_match(
|
||||
target_state_change_data.targeted_entity_ids
|
||||
# Note: It's enough to test for exactly 1 match here because if there
|
||||
# were previously 2 matches the transition would not be valid and we
|
||||
# would have returned already.
|
||||
if (
|
||||
self.count_matches(target_state_change_data.targeted_entity_ids)
|
||||
!= 1
|
||||
):
|
||||
return
|
||||
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"state of {entity_id}",
|
||||
event.context,
|
||||
@callback
|
||||
def call_action() -> None:
|
||||
"""Call action with right context."""
|
||||
# After a `for` delay, keep the original triggering event payload.
|
||||
# `async_track_same_state` only verifies the state remained valid
|
||||
# for the configured duration before firing the action.
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
"for": self._duration,
|
||||
},
|
||||
f"state of {entity_id}",
|
||||
event.context,
|
||||
)
|
||||
|
||||
if not self._duration:
|
||||
call_action()
|
||||
return
|
||||
|
||||
subscription_key = entity_id if behavior == BEHAVIOR_ANY else behavior
|
||||
if subscription_key in unsub_track_same:
|
||||
unsub_track_same.pop(subscription_key)()
|
||||
unsub_track_same[subscription_key] = async_track_same_state(
|
||||
self._hass,
|
||||
self._duration,
|
||||
call_action,
|
||||
state_still_valid,
|
||||
entity_ids=(
|
||||
entity_id
|
||||
if behavior == BEHAVIOR_ANY
|
||||
else target_state_change_data.targeted_entity_ids
|
||||
),
|
||||
)
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
unsub = async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, self.entity_filter
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_remove() -> None:
|
||||
"""Remove state listeners async."""
|
||||
unsub()
|
||||
for async_remove in unsub_track_same.values():
|
||||
async_remove()
|
||||
unsub_track_same.clear()
|
||||
|
||||
return async_remove
|
||||
|
||||
|
||||
class EntityTargetStateTriggerBase(EntityTriggerBase):
|
||||
"""Trigger for entity state changes to a specific state.
|
||||
|
||||
@@ -47,7 +47,7 @@ Jinja2==3.1.6
|
||||
lru-dict==1.3.0
|
||||
mutagen==1.47.0
|
||||
openai==2.21.0
|
||||
orjson==3.11.7
|
||||
orjson==3.11.8
|
||||
packaging>=23.1
|
||||
paho-mqtt==2.1.0
|
||||
Pillow==12.2.0
|
||||
@@ -64,7 +64,7 @@ PyTurboJPEG==1.8.0
|
||||
PyYAML==6.0.3
|
||||
requests==2.33.1
|
||||
securetar==2026.4.1
|
||||
SQLAlchemy==2.0.41
|
||||
SQLAlchemy==2.0.49
|
||||
standard-aifc==3.13.0
|
||||
standard-telnetlib==3.13.0
|
||||
typing-extensions>=4.15.0,<5.0
|
||||
|
||||
@@ -61,14 +61,14 @@ dependencies = [
|
||||
"Pillow==12.2.0",
|
||||
"propcache==0.4.1",
|
||||
"pyOpenSSL==26.0.0",
|
||||
"orjson==3.11.7",
|
||||
"orjson==3.11.8",
|
||||
"packaging>=23.1",
|
||||
"psutil-home-assistant==0.0.1",
|
||||
"python-slugify==8.0.4",
|
||||
"PyYAML==6.0.3",
|
||||
"requests==2.33.1",
|
||||
"securetar==2026.4.1",
|
||||
"SQLAlchemy==2.0.41",
|
||||
"SQLAlchemy==2.0.49",
|
||||
"standard-aifc==3.13.0",
|
||||
"standard-telnetlib==3.13.0",
|
||||
"typing-extensions>=4.15.0,<5.0",
|
||||
|
||||
4
requirements.txt
generated
4
requirements.txt
generated
@@ -34,7 +34,7 @@ infrared-protocols==1.1.0
|
||||
Jinja2==3.1.6
|
||||
lru-dict==1.3.0
|
||||
mutagen==1.47.0
|
||||
orjson==3.11.7
|
||||
orjson==3.11.8
|
||||
packaging>=23.1
|
||||
Pillow==12.2.0
|
||||
propcache==0.4.1
|
||||
@@ -48,7 +48,7 @@ PyTurboJPEG==1.8.0
|
||||
PyYAML==6.0.3
|
||||
requests==2.33.1
|
||||
securetar==2026.4.1
|
||||
SQLAlchemy==2.0.41
|
||||
SQLAlchemy==2.0.49
|
||||
standard-aifc==3.13.0
|
||||
standard-telnetlib==3.13.0
|
||||
typing-extensions>=4.15.0,<5.0
|
||||
|
||||
2
requirements_all.txt
generated
2
requirements_all.txt
generated
@@ -115,7 +115,7 @@ RtmAPI==0.7.2
|
||||
|
||||
# homeassistant.components.recorder
|
||||
# homeassistant.components.sql
|
||||
SQLAlchemy==2.0.41
|
||||
SQLAlchemy==2.0.49
|
||||
|
||||
# homeassistant.components.tami4
|
||||
Tami4EdgeAPI==3.0
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-c homeassistant/package_constraints.txt
|
||||
-r requirements_test_pre_commit.txt
|
||||
astroid==4.0.4
|
||||
coverage==7.10.6
|
||||
coverage==7.13.5
|
||||
freezegun==1.5.5
|
||||
# librt is an internal mypy dependency
|
||||
librt==0.8.1
|
||||
@@ -22,11 +22,11 @@ pylint-per-file-ignores==3.2.1
|
||||
pipdeptree==2.26.1
|
||||
pytest-asyncio==1.3.0
|
||||
pytest-aiohttp==1.1.0
|
||||
pytest-cov==7.0.0
|
||||
pytest-cov==7.1.0
|
||||
pytest-freezer==0.4.9
|
||||
pytest-github-actions-annotate-failures==0.3.0
|
||||
pytest-socket==0.7.0
|
||||
pytest-sugar==1.0.0
|
||||
pytest-sugar==1.1.1
|
||||
pytest-timeout==2.4.0
|
||||
pytest-unordered==0.7.0
|
||||
pytest-picked==0.5.1
|
||||
|
||||
2
requirements_test_all.txt
generated
2
requirements_test_all.txt
generated
@@ -112,7 +112,7 @@ RtmAPI==0.7.2
|
||||
|
||||
# homeassistant.components.recorder
|
||||
# homeassistant.components.sql
|
||||
SQLAlchemy==2.0.41
|
||||
SQLAlchemy==2.0.49
|
||||
|
||||
# homeassistant.components.tami4
|
||||
Tami4EdgeAPI==3.0
|
||||
|
||||
2
requirements_test_pre_commit.txt
generated
2
requirements_test_pre_commit.txt
generated
@@ -1,6 +1,6 @@
|
||||
# Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit
|
||||
|
||||
codespell==2.4.1
|
||||
codespell==2.4.2
|
||||
ruff==0.15.1
|
||||
yamllint==1.38.0
|
||||
zizmor==1.23.1
|
||||
|
||||
@@ -252,6 +252,12 @@ FORBIDDEN_PACKAGE_FILES_EXCEPTIONS = {
|
||||
"coinbase": {"homeassistant": {"coinbase-advanced-py"}},
|
||||
# https://github.com/u9n/dlms-cosem
|
||||
"dsmr": {"dsmr-parser": {"dlms-cosem"}},
|
||||
# https://github.com/tkdrob/pyefergy
|
||||
# pyefergy declares codecov as a runtime dependency, which pulls in
|
||||
# coverage; coverage ships an 'a1_coverage.pth' file starting from
|
||||
# 7.13.x. Upstream fix pending in
|
||||
# https://github.com/tkdrob/pyefergy/pull/47
|
||||
"efergy": {"codecov": {"coverage"}},
|
||||
# https://github.com/ChrisMandich/PyFlume # Fixed with >=0.7.1
|
||||
"fitbit": {
|
||||
# Setuptools - distutils-precedence.pth
|
||||
|
||||
@@ -9,12 +9,12 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.components.common import (
|
||||
ConditionStateDescription,
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
create_target_condition,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
@@ -22,17 +22,7 @@ from tests.components.common import (
|
||||
@pytest.fixture
|
||||
async def target_fans(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple fan entities associated with different targets."""
|
||||
return await target_entities(hass, "fan")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_switches(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple switch entities associated with different targets.
|
||||
|
||||
Note: The switches are used to ensure that only fan entities are considered
|
||||
in the condition evaluation and not other toggle entities.
|
||||
"""
|
||||
return await target_entities(hass, "switch")
|
||||
return await target_entities(hass, "fan", domain_excluded="switch")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -61,18 +51,19 @@ async def test_fan_conditions_gated_by_labs_flag(
|
||||
condition="fan.is_on",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
excluded_entities_from_other_domain=True,
|
||||
),
|
||||
*parametrize_condition_states_any(
|
||||
condition="fan.is_off",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
excluded_entities_from_other_domain=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_fan_state_condition_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
target_fans: dict[str, list[str]],
|
||||
target_switches: dict[str, list[str]],
|
||||
condition_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
@@ -81,39 +72,17 @@ async def test_fan_state_condition_behavior_any(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the fan state condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_fans["included_entities"]) - {entity_id}
|
||||
|
||||
# Set all fans, including the tested fan, to the initial state
|
||||
for eid in target_fans["included_entities"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_any(
|
||||
hass,
|
||||
target_entities=target_fans,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="any",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
# Set state for switches to ensure that they don't impact the condition
|
||||
for state in states:
|
||||
for eid in target_switches["included_entities"]:
|
||||
set_or_remove_state(hass, eid, state["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) is False
|
||||
|
||||
for state in states:
|
||||
included_state = state["included_state"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
# Check if changing other fans also passes the condition
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
@@ -127,11 +96,13 @@ async def test_fan_state_condition_behavior_any(
|
||||
condition="fan.is_on",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
excluded_entities_from_other_domain=True,
|
||||
),
|
||||
*parametrize_condition_states_all(
|
||||
condition="fan.is_off",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
excluded_entities_from_other_domain=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -146,33 +117,13 @@ async def test_fan_state_condition_behavior_all(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the fan state condition with the 'all' behavior."""
|
||||
# Set state for two switches to ensure that they don't impact the condition
|
||||
hass.states.async_set("switch.label_switch_1", STATE_OFF)
|
||||
hass.states.async_set("switch.label_switch_2", STATE_ON)
|
||||
|
||||
other_entity_ids = set(target_fans["included_entities"]) - {entity_id}
|
||||
|
||||
# Set all fans, including the tested fan, to the initial state
|
||||
for eid in target_fans["included_entities"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_all(
|
||||
hass,
|
||||
target_entities=target_fans,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="all",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included_state"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true_first_entity"]
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
@@ -13,11 +13,9 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
create_target_condition,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
@@ -135,17 +133,7 @@ def parametrize_brightness_condition_states_all(
|
||||
@pytest.fixture
|
||||
async def target_lights(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple light entities associated with different targets."""
|
||||
return await target_entities(hass, "light")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_switches(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple switch entities associated with different targets.
|
||||
|
||||
Note: The switches are used to ensure that only light entities are considered
|
||||
in the condition evaluation and not other toggle entities.
|
||||
"""
|
||||
return await target_entities(hass, "switch")
|
||||
return await target_entities(hass, "light", domain_excluded="switch")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -175,18 +163,19 @@ async def test_light_conditions_gated_by_labs_flag(
|
||||
condition="light.is_on",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
excluded_entities_from_other_domain=True,
|
||||
),
|
||||
*parametrize_condition_states_any(
|
||||
condition="light.is_off",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
excluded_entities_from_other_domain=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_light_state_condition_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
target_lights: dict[str, list[str]],
|
||||
target_switches: dict[str, list[str]],
|
||||
condition_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
@@ -195,39 +184,17 @@ async def test_light_state_condition_behavior_any(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the light state condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_lights["included_entities"]) - {entity_id}
|
||||
|
||||
# Set all lights, including the tested light, to the initial state
|
||||
for eid in target_lights["included_entities"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_any(
|
||||
hass,
|
||||
target_entities=target_lights,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="any",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
# Set state for switches to ensure that they don't impact the condition
|
||||
for state in states:
|
||||
for eid in target_switches["included_entities"]:
|
||||
set_or_remove_state(hass, eid, state["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) is False
|
||||
|
||||
for state in states:
|
||||
included_state = state["included_state"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
# Check if changing other lights also passes the condition
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
@@ -241,11 +208,13 @@ async def test_light_state_condition_behavior_any(
|
||||
condition="light.is_on",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
excluded_entities_from_other_domain=True,
|
||||
),
|
||||
*parametrize_condition_states_all(
|
||||
condition="light.is_off",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
excluded_entities_from_other_domain=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -260,37 +229,17 @@ async def test_light_state_condition_behavior_all(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the light state condition with the 'all' behavior."""
|
||||
# Set state for two switches to ensure that they don't impact the condition
|
||||
hass.states.async_set("switch.label_switch_1", STATE_OFF)
|
||||
hass.states.async_set("switch.label_switch_2", STATE_ON)
|
||||
|
||||
other_entity_ids = set(target_lights["included_entities"]) - {entity_id}
|
||||
|
||||
# Set all lights, including the tested light, to the initial state
|
||||
for eid in target_lights["included_entities"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_all(
|
||||
hass,
|
||||
target_entities=target_lights,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="all",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included_state"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true_first_entity"]
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -2830,7 +2830,7 @@ async def test_clean_up_registry_monitoring(
|
||||
}
|
||||
# Publish it config
|
||||
# Since it is not enabled_by_default the sensor will not be loaded
|
||||
# it should register a hook for monitoring the entiry registry
|
||||
# it should register a hook for monitoring the entity registry
|
||||
async_fire_mqtt_message(
|
||||
hass,
|
||||
"homeassistant/sensor/sbfspot_0/sbfspot_12345/config",
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -26,10 +25,8 @@ from homeassistant.components.scrape.const import (
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import (
|
||||
CONF_METHOD,
|
||||
CONF_NAME,
|
||||
CONF_RESOURCE,
|
||||
CONF_TIMEOUT,
|
||||
CONF_UNIQUE_ID,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -49,9 +46,9 @@ def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
yield mock_setup_entry
|
||||
|
||||
|
||||
@pytest.fixture(name="get_config")
|
||||
async def get_config_to_integration_load() -> dict[str, Any]:
|
||||
"""Return default minimal configuration.
|
||||
@pytest.fixture(name="get_resource_config")
|
||||
async def get_resource_config_to_integration_load() -> dict[str, Any]:
|
||||
"""Return default minimal configuration for resource.
|
||||
|
||||
To override the config, tests can be marked with:
|
||||
@pytest.mark.parametrize("get_config", [{...}])
|
||||
@@ -59,20 +56,33 @@ async def get_config_to_integration_load() -> dict[str, Any]:
|
||||
return {
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: DEFAULT_METHOD,
|
||||
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
|
||||
CONF_TIMEOUT: DEFAULT_TIMEOUT,
|
||||
CONF_ENCODING: DEFAULT_ENCODING,
|
||||
"sensor": [
|
||||
{
|
||||
CONF_NAME: "Current version",
|
||||
CONF_SELECT: ".current-version h1",
|
||||
CONF_INDEX: 0,
|
||||
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
|
||||
}
|
||||
],
|
||||
"auth": {},
|
||||
"advanced": {
|
||||
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
|
||||
CONF_TIMEOUT: DEFAULT_TIMEOUT,
|
||||
CONF_ENCODING: DEFAULT_ENCODING,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(name="get_sensor_config")
|
||||
async def get_sensor_config_to_integration_load() -> tuple[dict[str, Any], ...]:
|
||||
"""Return default minimal configuration for sensor.
|
||||
|
||||
To override the config, tests can be marked with:
|
||||
@pytest.mark.parametrize("get_config", [{...}])
|
||||
"""
|
||||
return (
|
||||
{
|
||||
"data": {"advanced": {}, CONF_INDEX: 0, CONF_SELECT: ".current-version h1"},
|
||||
"subentry_id": "01JZN07D8D23994A49YKS649S7",
|
||||
"subentry_type": "entity",
|
||||
"title": "Current version",
|
||||
"unique_id": None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="get_data")
|
||||
async def get_data_to_integration_load() -> MockRestData:
|
||||
"""Return RestData.
|
||||
@@ -85,14 +95,19 @@ async def get_data_to_integration_load() -> MockRestData:
|
||||
|
||||
@pytest.fixture(name="loaded_entry")
|
||||
async def load_integration(
|
||||
hass: HomeAssistant, get_config: dict[str, Any], get_data: MockRestData
|
||||
hass: HomeAssistant,
|
||||
get_resource_config: dict[str, Any],
|
||||
get_sensor_config: tuple[dict[str, Any], ...],
|
||||
get_data: MockRestData,
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the Scrape integration in Home Assistant."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
source=SOURCE_USER,
|
||||
options=get_config,
|
||||
entry_id="1",
|
||||
options=get_resource_config,
|
||||
entry_id="01JZN04ZJ9BQXXGXDS05WS7D6P",
|
||||
subentries_data=get_sensor_config,
|
||||
version=2,
|
||||
)
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
@@ -105,13 +120,3 @@ async def load_integration(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return config_entry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def uuid_fixture() -> str:
|
||||
"""Automatically path uuid generator."""
|
||||
with patch(
|
||||
"homeassistant.components.scrape.config_flow.uuid.uuid1",
|
||||
return_value=uuid.UUID("3699ef88-69e6-11ed-a1eb-0242ac120002"),
|
||||
):
|
||||
yield
|
||||
|
||||
153
tests/components/scrape/snapshots/test_init.ambr
Normal file
153
tests/components/scrape/snapshots/test_init.ambr
Normal file
@@ -0,0 +1,153 @@
|
||||
# serializer version: 1
|
||||
# name: test_migrate_from_version_1_to_2[device_registry]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': <DeviceEntryType.SERVICE: 'service'>,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'scrape',
|
||||
'01JZQ1G63X2DX66GZ9ZTFY9PEH',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Scrape',
|
||||
'model': None,
|
||||
'model_id': None,
|
||||
'name': 'Current version',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_migrate_from_version_1_to_2[entity_registry]
|
||||
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': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.current_version',
|
||||
'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': 'scrape',
|
||||
'previous_unique_id': 'a0bde946-5c96-11f0-b55f-0242ac110002',
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': '01JZQ1G63X2DX66GZ9ZTFY9PEH',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_migrate_from_version_1_to_2[post_migration_config_entry]
|
||||
ConfigEntrySnapshot({
|
||||
'data': dict({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'discovery_keys': dict({
|
||||
}),
|
||||
'domain': 'scrape',
|
||||
'entry_id': <ANY>,
|
||||
'minor_version': 1,
|
||||
'options': dict({
|
||||
'advanced': dict({
|
||||
'encoding': 'UTF-8',
|
||||
'timeout': 10.0,
|
||||
'verify_ssl': True,
|
||||
}),
|
||||
'auth': dict({
|
||||
'password': 'pass',
|
||||
'username': 'user',
|
||||
}),
|
||||
'method': 'GET',
|
||||
'resource': 'http://www.home-assistant.io',
|
||||
}),
|
||||
'pref_disable_new_entities': False,
|
||||
'pref_disable_polling': False,
|
||||
'source': 'user',
|
||||
'subentries': list([
|
||||
dict({
|
||||
'data': dict({
|
||||
'advanced': dict({
|
||||
'value_template': '{{ value }}',
|
||||
}),
|
||||
'index': 0,
|
||||
'select': '.release-date',
|
||||
}),
|
||||
'subentry_id': '01JZQ1G63X2DX66GZ9ZTFY9PEH',
|
||||
'subentry_type': 'entity',
|
||||
'title': 'Current version',
|
||||
'unique_id': None,
|
||||
}),
|
||||
]),
|
||||
'title': 'Mock Title',
|
||||
'unique_id': None,
|
||||
'version': 2,
|
||||
})
|
||||
# ---
|
||||
# name: test_migrate_from_version_1_to_2[pre_migration_config_entry]
|
||||
ConfigEntrySnapshot({
|
||||
'data': dict({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'discovery_keys': dict({
|
||||
}),
|
||||
'domain': 'scrape',
|
||||
'entry_id': <ANY>,
|
||||
'minor_version': 1,
|
||||
'options': dict({
|
||||
'encoding': 'UTF-8',
|
||||
'method': 'GET',
|
||||
'password': 'pass',
|
||||
'resource': 'http://www.home-assistant.io',
|
||||
'sensor': list([
|
||||
dict({
|
||||
'index': 0,
|
||||
'name': 'Current version',
|
||||
'select': '.release-date',
|
||||
'unique_id': 'a0bde946-5c96-11f0-b55f-0242ac110002',
|
||||
'value_template': '{{ value }}',
|
||||
}),
|
||||
]),
|
||||
'timeout': 10.0,
|
||||
'username': 'user',
|
||||
'verify_ssl': True,
|
||||
}),
|
||||
'pref_disable_new_entities': False,
|
||||
'pref_disable_polling': False,
|
||||
'source': 'user',
|
||||
'subentries': list([
|
||||
]),
|
||||
'title': 'Mock Title',
|
||||
'unique_id': None,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
import uuid
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.rest.data import ( # pylint: disable=hass-component-root-import
|
||||
@@ -14,25 +13,21 @@ from homeassistant.components.rest.schema import ( # pylint: disable=hass-compo
|
||||
)
|
||||
from homeassistant.components.scrape import DOMAIN
|
||||
from homeassistant.components.scrape.const import (
|
||||
CONF_ADVANCED,
|
||||
CONF_AUTH,
|
||||
CONF_ENCODING,
|
||||
CONF_INDEX,
|
||||
CONF_SELECT,
|
||||
DEFAULT_ENCODING,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.components.sensor import CONF_STATE_CLASS
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_METHOD,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_PAYLOAD,
|
||||
CONF_RESOURCE,
|
||||
CONF_TIMEOUT,
|
||||
CONF_UNIQUE_ID,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_USERNAME,
|
||||
CONF_VALUE_TEMPLATE,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -44,7 +39,7 @@ from . import MockRestData
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_form(
|
||||
async def test_entry_and_subentry(
|
||||
hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test we get the form."""
|
||||
@@ -59,47 +54,55 @@ async def test_form(
|
||||
"homeassistant.components.rest.RestData",
|
||||
return_value=get_data,
|
||||
) as mock_data:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
CONF_AUTH: {},
|
||||
CONF_ADVANCED: {
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
},
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
result3 = await hass.config_entries.flow.async_configure(
|
||||
result2["flow_id"],
|
||||
{
|
||||
CONF_NAME: "Current version",
|
||||
CONF_SELECT: ".current-version h1",
|
||||
CONF_INDEX: 0.0,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result3["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result3["version"] == 1
|
||||
assert result3["options"] == {
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["version"] == 2
|
||||
assert result["options"] == {
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
CONF_ENCODING: "UTF-8",
|
||||
"sensor": [
|
||||
{
|
||||
CONF_NAME: "Current version",
|
||||
CONF_SELECT: ".current-version h1",
|
||||
CONF_INDEX: 0.0,
|
||||
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
|
||||
}
|
||||
],
|
||||
CONF_AUTH: {},
|
||||
CONF_ADVANCED: {
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
CONF_ENCODING: "UTF-8",
|
||||
},
|
||||
}
|
||||
|
||||
assert len(mock_data.mock_calls) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
entry_id = result["result"].entry_id
|
||||
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(entry_id, "entity"), context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["step_id"] == "user"
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_INDEX: 0, CONF_SELECT: ".current-version h1", CONF_ADVANCED: {}},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_INDEX: 0,
|
||||
CONF_SELECT: ".current-version h1",
|
||||
CONF_ADVANCED: {},
|
||||
}
|
||||
|
||||
|
||||
async def test_form_with_post(
|
||||
hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock
|
||||
@@ -116,44 +119,32 @@ async def test_form_with_post(
|
||||
"homeassistant.components.rest.RestData",
|
||||
return_value=get_data,
|
||||
) as mock_data:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_PAYLOAD: "POST",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
CONF_AUTH: {},
|
||||
CONF_ADVANCED: {
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
},
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
result3 = await hass.config_entries.flow.async_configure(
|
||||
result2["flow_id"],
|
||||
{
|
||||
CONF_NAME: "Current version",
|
||||
CONF_SELECT: ".current-version h1",
|
||||
CONF_INDEX: 0.0,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result3["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result3["version"] == 1
|
||||
assert result3["options"] == {
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["version"] == 2
|
||||
assert result["options"] == {
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_PAYLOAD: "POST",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
CONF_ENCODING: "UTF-8",
|
||||
"sensor": [
|
||||
{
|
||||
CONF_NAME: "Current version",
|
||||
CONF_SELECT: ".current-version h1",
|
||||
CONF_INDEX: 0.0,
|
||||
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
|
||||
}
|
||||
],
|
||||
CONF_AUTH: {},
|
||||
CONF_ADVANCED: {
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
CONF_ENCODING: "UTF-8",
|
||||
},
|
||||
}
|
||||
|
||||
assert len(mock_data.mock_calls) == 1
|
||||
@@ -176,74 +167,68 @@ async def test_flow_fails(
|
||||
"homeassistant.components.rest.RestData",
|
||||
side_effect=HomeAssistantError,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
CONF_AUTH: {},
|
||||
CONF_ADVANCED: {
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["errors"] == {"base": "resource_error"}
|
||||
assert result["errors"] == {"base": "resource_error"}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.rest.RestData",
|
||||
return_value=MockRestData("test_scrape_sensor_no_data"),
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
CONF_AUTH: {},
|
||||
CONF_ADVANCED: {
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["errors"] == {"base": "resource_error"}
|
||||
assert result["errors"] == {"base": "no_data"}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.rest.RestData",
|
||||
return_value=get_data,
|
||||
):
|
||||
result3 = await hass.config_entries.flow.async_configure(
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
CONF_AUTH: {},
|
||||
CONF_ADVANCED: {
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
},
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
result4 = await hass.config_entries.flow.async_configure(
|
||||
result3["flow_id"],
|
||||
{
|
||||
CONF_NAME: "Current version",
|
||||
CONF_SELECT: ".current-version h1",
|
||||
CONF_INDEX: 0.0,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result4["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result4["title"] == "https://www.home-assistant.io"
|
||||
assert result4["options"] == {
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "https://www.home-assistant.io"
|
||||
assert result["options"] == {
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
CONF_ENCODING: "UTF-8",
|
||||
"sensor": [
|
||||
{
|
||||
CONF_NAME: "Current version",
|
||||
CONF_SELECT: ".current-version h1",
|
||||
CONF_INDEX: 0.0,
|
||||
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
|
||||
}
|
||||
],
|
||||
CONF_AUTH: {},
|
||||
CONF_ADVANCED: {
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
CONF_ENCODING: "UTF-8",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -257,16 +242,8 @@ async def test_options_resource_flow(
|
||||
|
||||
result = await hass.config_entries.options.async_init(loaded_entry.entry_id)
|
||||
|
||||
assert result["type"] is FlowResultType.MENU
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"next_step_id": "resource"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "resource"
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
mocker = MockRestData("test_scrape_sensor2")
|
||||
with patch("homeassistant.components.rest.RestData", return_value=mocker):
|
||||
@@ -275,11 +252,15 @@ async def test_options_resource_flow(
|
||||
user_input={
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: DEFAULT_METHOD,
|
||||
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
|
||||
CONF_TIMEOUT: DEFAULT_TIMEOUT,
|
||||
CONF_ENCODING: DEFAULT_ENCODING,
|
||||
CONF_USERNAME: "secret_username",
|
||||
CONF_PASSWORD: "secret_password",
|
||||
CONF_AUTH: {
|
||||
CONF_USERNAME: "secret_username",
|
||||
CONF_PASSWORD: "secret_password",
|
||||
},
|
||||
CONF_ADVANCED: {
|
||||
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
|
||||
CONF_TIMEOUT: DEFAULT_TIMEOUT,
|
||||
CONF_ENCODING: DEFAULT_ENCODING,
|
||||
},
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
@@ -288,19 +269,15 @@ async def test_options_resource_flow(
|
||||
assert result["data"] == {
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
CONF_ENCODING: "UTF-8",
|
||||
CONF_USERNAME: "secret_username",
|
||||
CONF_PASSWORD: "secret_password",
|
||||
"sensor": [
|
||||
{
|
||||
CONF_NAME: "Current version",
|
||||
CONF_SELECT: ".current-version h1",
|
||||
CONF_INDEX: 0.0,
|
||||
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
|
||||
}
|
||||
],
|
||||
CONF_AUTH: {
|
||||
CONF_USERNAME: "secret_username",
|
||||
CONF_PASSWORD: "secret_password",
|
||||
},
|
||||
CONF_ADVANCED: {
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
CONF_ENCODING: "UTF-8",
|
||||
},
|
||||
}
|
||||
|
||||
await hass.async_block_till_done()
|
||||
@@ -311,351 +288,3 @@ async def test_options_resource_flow(
|
||||
# Check the state of the entity has changed as expected
|
||||
state = hass.states.get("sensor.current_version")
|
||||
assert state.state == "Hidden Version: 2021.12.10"
|
||||
|
||||
|
||||
async def test_options_add_remove_sensor_flow(
|
||||
hass: HomeAssistant, loaded_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test options flow to add and remove a sensor."""
|
||||
|
||||
state = hass.states.get("sensor.current_version")
|
||||
assert state.state == "Current Version: 2021.12.10"
|
||||
|
||||
result = await hass.config_entries.options.async_init(loaded_entry.entry_id)
|
||||
|
||||
assert result["type"] is FlowResultType.MENU
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"next_step_id": "add_sensor"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "add_sensor"
|
||||
|
||||
mocker = MockRestData("test_scrape_sensor2")
|
||||
with (
|
||||
patch("homeassistant.components.rest.RestData", return_value=mocker),
|
||||
patch(
|
||||
"homeassistant.components.scrape.config_flow.uuid.uuid1",
|
||||
return_value=uuid.UUID("3699ef88-69e6-11ed-a1eb-0242ac120003"),
|
||||
),
|
||||
):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_NAME: "Template",
|
||||
CONF_SELECT: "template",
|
||||
CONF_INDEX: 0.0,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10,
|
||||
CONF_ENCODING: "UTF-8",
|
||||
"sensor": [
|
||||
{
|
||||
CONF_NAME: "Current version",
|
||||
CONF_SELECT: ".current-version h1",
|
||||
CONF_INDEX: 0,
|
||||
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
|
||||
},
|
||||
{
|
||||
CONF_NAME: "Template",
|
||||
CONF_SELECT: "template",
|
||||
CONF_INDEX: 0,
|
||||
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120003",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Check the entity was updated, with the new entity
|
||||
assert len(hass.states.async_all()) == 2
|
||||
|
||||
# Check the state of the entity has changed as expected
|
||||
state = hass.states.get("sensor.current_version")
|
||||
assert state.state == "Hidden Version: 2021.12.10"
|
||||
|
||||
state = hass.states.get("sensor.template")
|
||||
assert state.state == "Trying to get"
|
||||
|
||||
# Now remove the original sensor
|
||||
|
||||
result = await hass.config_entries.options.async_init(loaded_entry.entry_id)
|
||||
|
||||
assert result["type"] is FlowResultType.MENU
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"next_step_id": "remove_sensor"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "remove_sensor"
|
||||
|
||||
mocker = MockRestData("test_scrape_sensor2")
|
||||
with patch("homeassistant.components.rest.RestData", return_value=mocker):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_INDEX: ["0"],
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10,
|
||||
CONF_ENCODING: "UTF-8",
|
||||
"sensor": [
|
||||
{
|
||||
CONF_NAME: "Template",
|
||||
CONF_SELECT: "template",
|
||||
CONF_INDEX: 0,
|
||||
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120003",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Check the original entity was removed, with only the new entity left
|
||||
assert len(hass.states.async_all()) == 1
|
||||
|
||||
# Check the state of the new entity
|
||||
state = hass.states.get("sensor.template")
|
||||
assert state.state == "Trying to get"
|
||||
|
||||
|
||||
async def test_options_edit_sensor_flow(
|
||||
hass: HomeAssistant, loaded_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test options flow to edit a sensor."""
|
||||
|
||||
state = hass.states.get("sensor.current_version")
|
||||
assert state.state == "Current Version: 2021.12.10"
|
||||
|
||||
result = await hass.config_entries.options.async_init(loaded_entry.entry_id)
|
||||
|
||||
assert result["type"] is FlowResultType.MENU
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"next_step_id": "select_edit_sensor"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "select_edit_sensor"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"index": "0"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "edit_sensor"
|
||||
|
||||
mocker = MockRestData("test_scrape_sensor2")
|
||||
with patch("homeassistant.components.rest.RestData", return_value=mocker):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_SELECT: "template",
|
||||
CONF_INDEX: 0.0,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10,
|
||||
CONF_ENCODING: "UTF-8",
|
||||
"sensor": [
|
||||
{
|
||||
CONF_NAME: "Current version",
|
||||
CONF_SELECT: "template",
|
||||
CONF_INDEX: 0,
|
||||
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Check the entity was updated
|
||||
assert len(hass.states.async_all()) == 1
|
||||
|
||||
# Check the state of the entity has changed as expected
|
||||
state = hass.states.get("sensor.current_version")
|
||||
assert state.state == "Trying to get"
|
||||
|
||||
|
||||
async def test_sensor_options_add_device_class(
|
||||
hass: HomeAssistant, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test options flow to edit a sensor."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
options={
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: DEFAULT_METHOD,
|
||||
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
|
||||
CONF_TIMEOUT: DEFAULT_TIMEOUT,
|
||||
CONF_ENCODING: DEFAULT_ENCODING,
|
||||
"sensor": [
|
||||
{
|
||||
CONF_NAME: "Current Temp",
|
||||
CONF_SELECT: ".current-temp h3",
|
||||
CONF_INDEX: 0,
|
||||
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
|
||||
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
|
||||
}
|
||||
],
|
||||
},
|
||||
entry_id="1",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
assert result["type"] is FlowResultType.MENU
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"next_step_id": "select_edit_sensor"},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "select_edit_sensor"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"index": "0"},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "edit_sensor"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_SELECT: ".current-temp h3",
|
||||
CONF_INDEX: 0.0,
|
||||
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
|
||||
CONF_DEVICE_CLASS: "temperature",
|
||||
CONF_STATE_CLASS: "measurement",
|
||||
CONF_UNIT_OF_MEASUREMENT: "°C",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10,
|
||||
CONF_ENCODING: "UTF-8",
|
||||
"sensor": [
|
||||
{
|
||||
CONF_NAME: "Current Temp",
|
||||
CONF_SELECT: ".current-temp h3",
|
||||
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
|
||||
CONF_INDEX: 0,
|
||||
CONF_DEVICE_CLASS: "temperature",
|
||||
CONF_STATE_CLASS: "measurement",
|
||||
CONF_UNIT_OF_MEASUREMENT: "°C",
|
||||
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def test_sensor_options_remove_device_class(
|
||||
hass: HomeAssistant, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test options flow to edit a sensor."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
options={
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: DEFAULT_METHOD,
|
||||
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
|
||||
CONF_TIMEOUT: DEFAULT_TIMEOUT,
|
||||
CONF_ENCODING: DEFAULT_ENCODING,
|
||||
"sensor": [
|
||||
{
|
||||
CONF_NAME: "Current Temp",
|
||||
CONF_SELECT: ".current-temp h3",
|
||||
CONF_INDEX: 0,
|
||||
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
|
||||
CONF_DEVICE_CLASS: "temperature",
|
||||
CONF_STATE_CLASS: "measurement",
|
||||
CONF_UNIT_OF_MEASUREMENT: "°C",
|
||||
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
|
||||
}
|
||||
],
|
||||
},
|
||||
entry_id="1",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
assert result["type"] is FlowResultType.MENU
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"next_step_id": "select_edit_sensor"},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "select_edit_sensor"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"index": "0"},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "edit_sensor"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_SELECT: ".current-temp h3",
|
||||
CONF_INDEX: 0.0,
|
||||
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10,
|
||||
CONF_ENCODING: "UTF-8",
|
||||
"sensor": [
|
||||
{
|
||||
CONF_NAME: "Current Temp",
|
||||
CONF_SELECT: ".current-temp h3",
|
||||
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
|
||||
CONF_INDEX: 0,
|
||||
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.scrape.const import DEFAULT_SCAN_INTERVAL, DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER, ConfigEntryState, ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
@@ -193,3 +197,137 @@ async def test_resource_template(
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
state = hass.states.get("sensor.template_sensor")
|
||||
assert state.state == "Second"
|
||||
|
||||
|
||||
async def test_migrate_from_future(
|
||||
hass: HomeAssistant,
|
||||
get_resource_config: dict[str, Any],
|
||||
get_sensor_config: tuple[dict[str, Any], ...],
|
||||
get_data: MockRestData,
|
||||
) -> None:
|
||||
"""Test migration from future version fails."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
source=SOURCE_USER,
|
||||
options=get_resource_config,
|
||||
entry_id="01JZN04ZJ9BQXXGXDS05WS7D6P",
|
||||
subentries_data=get_sensor_config,
|
||||
version=3,
|
||||
)
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.rest.RestData",
|
||||
return_value=get_data,
|
||||
):
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is ConfigEntryState.MIGRATION_ERROR
|
||||
|
||||
|
||||
async def test_migrate_from_version_1_to_2(
|
||||
hass: HomeAssistant,
|
||||
get_data: MockRestData,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test migration from version 1.1 to 2.1 with config subentries."""
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MockConfigSubentry(ConfigSubentry):
|
||||
"""Container for a configuration subentry."""
|
||||
|
||||
subentry_id: str = "01JZQ1G63X2DX66GZ9ZTFY9PEH"
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
source=SOURCE_USER,
|
||||
options={
|
||||
"encoding": "UTF-8",
|
||||
"method": "GET",
|
||||
"resource": "http://www.home-assistant.io",
|
||||
"username": "user",
|
||||
"password": "pass",
|
||||
"sensor": [
|
||||
{
|
||||
"index": 0,
|
||||
"name": "Current version",
|
||||
"select": ".release-date",
|
||||
"unique_id": "a0bde946-5c96-11f0-b55f-0242ac110002",
|
||||
"value_template": "{{ value }}",
|
||||
}
|
||||
],
|
||||
"timeout": 10.0,
|
||||
"verify_ssl": True,
|
||||
},
|
||||
entry_id="01JZN04ZJ9BQXXGXDS05WS7D6P",
|
||||
version=1,
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
device = device_registry.async_get_or_create(
|
||||
config_entry_id=config_entry.entry_id,
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, "a0bde946-5c96-11f0-b55f-0242ac110002")},
|
||||
manufacturer="Scrape",
|
||||
name="Current version",
|
||||
)
|
||||
entity_registry.async_get_or_create(
|
||||
SENSOR_DOMAIN,
|
||||
DOMAIN,
|
||||
"a0bde946-5c96-11f0-b55f-0242ac110002",
|
||||
config_entry=config_entry,
|
||||
device_id=device.id,
|
||||
original_name="Current version",
|
||||
has_entity_name=True,
|
||||
suggested_object_id="current_version",
|
||||
)
|
||||
assert hass.config_entries.async_get_entry(config_entry.entry_id) == snapshot(
|
||||
name="pre_migration_config_entry"
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.rest.RestData",
|
||||
return_value=get_data,
|
||||
),
|
||||
patch("homeassistant.components.scrape.ConfigSubentry", MockConfigSubentry),
|
||||
):
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
assert hass.config_entries.async_get_entry(config_entry.entry_id) == snapshot(
|
||||
name="post_migration_config_entry"
|
||||
)
|
||||
device = device_registry.async_get(device.id)
|
||||
assert device == snapshot(name="device_registry")
|
||||
entity = entity_registry.async_get("sensor.current_version")
|
||||
assert entity == snapshot(name="entity_registry")
|
||||
|
||||
assert config_entry.subentries == {
|
||||
"01JZQ1G63X2DX66GZ9ZTFY9PEH": MockConfigSubentry(
|
||||
data={
|
||||
"advanced": {"value_template": "{{ value }}"},
|
||||
"index": 0,
|
||||
"select": ".release-date",
|
||||
},
|
||||
subentry_id="01JZQ1G63X2DX66GZ9ZTFY9PEH",
|
||||
subentry_type="entity",
|
||||
title="Current version",
|
||||
unique_id=None,
|
||||
),
|
||||
}
|
||||
assert device.config_entries == {"01JZN04ZJ9BQXXGXDS05WS7D6P"}
|
||||
assert device.config_entries_subentries == {
|
||||
"01JZN04ZJ9BQXXGXDS05WS7D6P": {
|
||||
"01JZQ1G63X2DX66GZ9ZTFY9PEH",
|
||||
},
|
||||
}
|
||||
assert entity.config_entry_id == config_entry.entry_id
|
||||
assert entity.config_subentry_id == "01JZQ1G63X2DX66GZ9ZTFY9PEH"
|
||||
|
||||
state = hass.states.get("sensor.current_version")
|
||||
assert state.state == "January 17, 2022"
|
||||
|
||||
@@ -18,7 +18,6 @@ from homeassistant.components.scrape.const import (
|
||||
)
|
||||
from homeassistant.components.sensor import (
|
||||
CONF_STATE_CLASS,
|
||||
DOMAIN as SENSOR_DOMAIN,
|
||||
SensorDeviceClass,
|
||||
SensorStateClass,
|
||||
)
|
||||
@@ -604,7 +603,7 @@ async def test_setup_config_entry(
|
||||
|
||||
entity = entity_registry.async_get("sensor.current_version")
|
||||
|
||||
assert entity.unique_id == "3699ef88-69e6-11ed-a1eb-0242ac120002"
|
||||
assert entity.unique_id == "01JZN07D8D23994A49YKS649S7"
|
||||
|
||||
|
||||
async def test_templates_with_yaml(hass: HomeAssistant) -> None:
|
||||
@@ -688,27 +687,38 @@ async def test_templates_with_yaml(hass: HomeAssistant) -> None:
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"get_config",
|
||||
("get_resource_config", "get_sensor_config"),
|
||||
[
|
||||
{
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
|
||||
CONF_TIMEOUT: 10,
|
||||
CONF_ENCODING: DEFAULT_ENCODING,
|
||||
SENSOR_DOMAIN: [
|
||||
(
|
||||
{
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
"auth": {},
|
||||
"advanced": {
|
||||
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
|
||||
CONF_TIMEOUT: 10,
|
||||
CONF_ENCODING: DEFAULT_ENCODING,
|
||||
},
|
||||
},
|
||||
(
|
||||
{
|
||||
CONF_SELECT: ".current-version h1",
|
||||
CONF_NAME: "Current version",
|
||||
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
|
||||
CONF_INDEX: 0,
|
||||
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
|
||||
CONF_AVAILABILITY: '{{ states("sensor.input1")=="on" }}',
|
||||
CONF_ICON: 'mdi:o{{ "n" if states("sensor.input1")=="on" else "ff" }}',
|
||||
CONF_PICTURE: 'o{{ "n" if states("sensor.input1")=="on" else "ff" }}.jpg',
|
||||
}
|
||||
],
|
||||
}
|
||||
"data": {
|
||||
CONF_SELECT: ".current-version h1",
|
||||
CONF_INDEX: 0,
|
||||
"advanced": {
|
||||
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
|
||||
CONF_AVAILABILITY: '{{ states("sensor.input1")=="on" }}',
|
||||
CONF_ICON: 'mdi:o{{ "n" if states("sensor.input1")=="on" else "ff" }}',
|
||||
CONF_PICTURE: 'o{{ "n" if states("sensor.input1")=="on" else "ff" }}.jpg',
|
||||
},
|
||||
},
|
||||
# "subentry_id": "01JZN07D8D23994A49YKS649S7",
|
||||
"subentry_type": "entity",
|
||||
"title": "Current version",
|
||||
"unique_id": None,
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
async def test_availability(
|
||||
|
||||
@@ -9,12 +9,12 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.components.common import (
|
||||
ConditionStateDescription,
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
create_target_condition,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
@@ -22,17 +22,7 @@ from tests.components.common import (
|
||||
@pytest.fixture
|
||||
async def target_sirens(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple siren entities associated with different targets."""
|
||||
return await target_entities(hass, "siren")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_switches(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple switch entities associated with different targets.
|
||||
|
||||
Note: The switches are used to ensure that only siren entities are considered
|
||||
in the condition evaluation and not other toggle entities.
|
||||
"""
|
||||
return await target_entities(hass, "switch")
|
||||
return await target_entities(hass, "siren", domain_excluded="switch")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -61,18 +51,19 @@ async def test_siren_conditions_gated_by_labs_flag(
|
||||
condition="siren.is_on",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
excluded_entities_from_other_domain=True,
|
||||
),
|
||||
*parametrize_condition_states_any(
|
||||
condition="siren.is_off",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
excluded_entities_from_other_domain=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_siren_state_condition_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
target_sirens: dict[str, list[str]],
|
||||
target_switches: dict[str, list[str]],
|
||||
condition_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
@@ -81,39 +72,17 @@ async def test_siren_state_condition_behavior_any(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the siren state condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_sirens["included_entities"]) - {entity_id}
|
||||
|
||||
# Set all sirens, including the tested siren, to the initial state
|
||||
for eid in target_sirens["included_entities"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_any(
|
||||
hass,
|
||||
target_entities=target_sirens,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="any",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
# Set state for switches to ensure that they don't impact the condition
|
||||
for state in states:
|
||||
for eid in target_switches["included_entities"]:
|
||||
set_or_remove_state(hass, eid, state["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) is False
|
||||
|
||||
for state in states:
|
||||
included_state = state["included_state"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
# Check if changing other sirens also passes the condition
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
@@ -127,11 +96,13 @@ async def test_siren_state_condition_behavior_any(
|
||||
condition="siren.is_on",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
excluded_entities_from_other_domain=True,
|
||||
),
|
||||
*parametrize_condition_states_all(
|
||||
condition="siren.is_off",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
excluded_entities_from_other_domain=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -146,32 +117,13 @@ async def test_siren_state_condition_behavior_all(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the siren state condition with the 'all' behavior."""
|
||||
# Set state for two switches to ensure that they don't impact the condition
|
||||
hass.states.async_set("switch.label_switch_1", STATE_OFF)
|
||||
hass.states.async_set("switch.label_switch_2", STATE_ON)
|
||||
other_entity_ids = set(target_sirens["included_entities"]) - {entity_id}
|
||||
|
||||
# Set all sirens, including the tested siren, to the initial state
|
||||
for eid in target_sirens["included_entities"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_all(
|
||||
hass,
|
||||
target_entities=target_sirens,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="all",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included_state"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true_first_entity"]
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
@@ -16,25 +16,14 @@ from tests.components.common import (
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_lights(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple light entities associated with different targets.
|
||||
|
||||
Note: The lights are used to ensure that only switch entities are considered
|
||||
in the condition evaluation and not other toggle entities.
|
||||
"""
|
||||
return await target_entities(hass, "light")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_switches(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple switch entities associated with different targets."""
|
||||
return await target_entities(hass, "switch")
|
||||
return await target_entities(hass, "switch", domain_excluded="light")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -69,17 +58,18 @@ async def test_switch_conditions_gated_by_labs_flag(
|
||||
condition="switch.is_on",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
excluded_entities_from_other_domain=True,
|
||||
),
|
||||
*parametrize_condition_states_any(
|
||||
condition="switch.is_off",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
excluded_entities_from_other_domain=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_switch_state_condition_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
target_lights: dict[str, list[str]],
|
||||
target_switches: dict[str, list[str]],
|
||||
condition_target_config: dict,
|
||||
entity_id: str,
|
||||
@@ -89,39 +79,17 @@ async def test_switch_state_condition_behavior_any(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the switch state condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_switches["included_entities"]) - {entity_id}
|
||||
|
||||
# Set all switches, including the tested switch, to the initial state
|
||||
for eid in target_switches["included_entities"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_any(
|
||||
hass,
|
||||
target_entities=target_switches,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="any",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
# Set state for lights to ensure that they don't impact the condition
|
||||
for state in states:
|
||||
for eid in target_lights["included_entities"]:
|
||||
set_or_remove_state(hass, eid, state["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) is False
|
||||
|
||||
for state in states:
|
||||
included_state = state["included_state"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
# Check if changing other lights also passes the condition
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
@@ -135,11 +103,13 @@ async def test_switch_state_condition_behavior_any(
|
||||
condition="switch.is_on",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
excluded_entities_from_other_domain=True,
|
||||
),
|
||||
*parametrize_condition_states_all(
|
||||
condition="switch.is_off",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
excluded_entities_from_other_domain=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -154,37 +124,17 @@ async def test_switch_state_condition_behavior_all(
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the switch state condition with the 'all' behavior."""
|
||||
# Set state for two switches to ensure that they don't impact the condition
|
||||
hass.states.async_set("switch.label_switch_1", STATE_OFF)
|
||||
hass.states.async_set("switch.label_switch_2", STATE_ON)
|
||||
|
||||
other_entity_ids = set(target_switches["included_entities"]) - {entity_id}
|
||||
|
||||
# Set all switches, including the tested switch, to the initial state
|
||||
for eid in target_switches["included_entities"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included_state"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
await assert_condition_behavior_all(
|
||||
hass,
|
||||
target_entities=target_switches,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="all",
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included_state"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true_first_entity"]
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
CONDITION_STATES = [
|
||||
*parametrize_condition_states_any(
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.timer import (
|
||||
@@ -33,7 +34,6 @@ from homeassistant.components.timer import (
|
||||
STATUS_IDLE,
|
||||
STATUS_PAUSED,
|
||||
Timer,
|
||||
_format_timedelta,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_EDITABLE,
|
||||
@@ -132,20 +132,27 @@ async def test_config_options(hass: HomeAssistant) -> None:
|
||||
assert state_3 is not None
|
||||
|
||||
assert state_1.state == STATUS_IDLE
|
||||
assert ATTR_ICON not in state_1.attributes
|
||||
assert ATTR_FRIENDLY_NAME not in state_1.attributes
|
||||
assert state_1.attributes == {
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_DURATION: "0:00:00",
|
||||
}
|
||||
|
||||
assert state_2.state == STATUS_IDLE
|
||||
assert state_2.attributes.get(ATTR_FRIENDLY_NAME) == "Hello World"
|
||||
assert state_2.attributes.get(ATTR_ICON) == "mdi:work"
|
||||
assert state_2.attributes.get(ATTR_DURATION) == "0:00:10"
|
||||
assert state_2.attributes == {
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FRIENDLY_NAME: "Hello World",
|
||||
ATTR_ICON: "mdi:work",
|
||||
}
|
||||
|
||||
assert state_3.state == STATUS_IDLE
|
||||
assert str(cv.time_period(DEFAULT_DURATION)) == state_3.attributes.get(
|
||||
CONF_DURATION
|
||||
)
|
||||
assert state_3.attributes == {
|
||||
ATTR_DURATION: str(cv.time_period(DEFAULT_DURATION)),
|
||||
ATTR_EDITABLE: False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2023-06-05 17:47:50")
|
||||
async def test_methods_and_events(hass: HomeAssistant) -> None:
|
||||
"""Test methods and events."""
|
||||
hass.set_state(CoreState.starting)
|
||||
@@ -155,13 +162,17 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_IDLE
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
}
|
||||
|
||||
results: list[tuple[Event, str]] = []
|
||||
results: list[tuple[Event, State | None]] = []
|
||||
|
||||
@callback
|
||||
def fake_event_listener(event: Event):
|
||||
"""Fake event listener for trigger."""
|
||||
results.append((event, hass.states.get("timer.test1").state))
|
||||
results.append((event, hass.states.get("timer.test1")))
|
||||
|
||||
hass.bus.async_listen(EVENT_TIMER_STARTED, fake_event_listener)
|
||||
hass.bus.async_listen(EVENT_TIMER_RESTARTED, fake_event_listener)
|
||||
@@ -170,102 +181,142 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
|
||||
hass.bus.async_listen(EVENT_TIMER_CANCELLED, fake_event_listener)
|
||||
hass.bus.async_listen(EVENT_TIMER_CHANGED, fake_event_listener)
|
||||
|
||||
finish_10 = (utcnow() + timedelta(seconds=10)).isoformat()
|
||||
finish_5 = (utcnow() + timedelta(seconds=5)).isoformat()
|
||||
|
||||
steps = [
|
||||
{
|
||||
"call": SERVICE_START,
|
||||
"state": STATUS_ACTIVE,
|
||||
"event": EVENT_TIMER_STARTED,
|
||||
"data": {},
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_ACTIVE,
|
||||
"expected_extra_attributes": {
|
||||
ATTR_FINISHES_AT: finish_10,
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
},
|
||||
"expected_event": EVENT_TIMER_STARTED,
|
||||
},
|
||||
{
|
||||
"call": SERVICE_PAUSE,
|
||||
"state": STATUS_PAUSED,
|
||||
"event": EVENT_TIMER_PAUSED,
|
||||
"data": {},
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_PAUSED,
|
||||
"expected_extra_attributes": {ATTR_REMAINING: "0:00:10"},
|
||||
"expected_event": EVENT_TIMER_PAUSED,
|
||||
},
|
||||
{
|
||||
"call": SERVICE_START,
|
||||
"state": STATUS_ACTIVE,
|
||||
"event": EVENT_TIMER_RESTARTED,
|
||||
"data": {},
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_ACTIVE,
|
||||
"expected_extra_attributes": {
|
||||
ATTR_FINISHES_AT: finish_10,
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
},
|
||||
"expected_event": EVENT_TIMER_RESTARTED,
|
||||
},
|
||||
{
|
||||
"call": SERVICE_CANCEL,
|
||||
"state": STATUS_IDLE,
|
||||
"event": EVENT_TIMER_CANCELLED,
|
||||
"data": {},
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_IDLE,
|
||||
"expected_extra_attributes": {},
|
||||
"expected_event": EVENT_TIMER_CANCELLED,
|
||||
},
|
||||
{
|
||||
"call": SERVICE_CANCEL,
|
||||
"state": STATUS_IDLE,
|
||||
"event": None,
|
||||
"data": {},
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_IDLE,
|
||||
"expected_extra_attributes": {},
|
||||
"expected_event": None,
|
||||
},
|
||||
{
|
||||
"call": SERVICE_START,
|
||||
"state": STATUS_ACTIVE,
|
||||
"event": EVENT_TIMER_STARTED,
|
||||
"data": {},
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_ACTIVE,
|
||||
"expected_extra_attributes": {
|
||||
ATTR_FINISHES_AT: finish_10,
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
},
|
||||
"expected_event": EVENT_TIMER_STARTED,
|
||||
},
|
||||
{
|
||||
"call": SERVICE_FINISH,
|
||||
"state": STATUS_IDLE,
|
||||
"event": EVENT_TIMER_FINISHED,
|
||||
"data": {},
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_IDLE,
|
||||
"expected_extra_attributes": {},
|
||||
"expected_event": EVENT_TIMER_FINISHED,
|
||||
},
|
||||
{
|
||||
"call": SERVICE_FINISH,
|
||||
"state": STATUS_IDLE,
|
||||
"event": None,
|
||||
"data": {},
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_IDLE,
|
||||
"expected_extra_attributes": {},
|
||||
"expected_event": None,
|
||||
},
|
||||
{
|
||||
"call": SERVICE_START,
|
||||
"state": STATUS_ACTIVE,
|
||||
"event": EVENT_TIMER_STARTED,
|
||||
"data": {},
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_ACTIVE,
|
||||
"expected_extra_attributes": {
|
||||
ATTR_FINISHES_AT: finish_10,
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
},
|
||||
"expected_event": EVENT_TIMER_STARTED,
|
||||
},
|
||||
{
|
||||
"call": SERVICE_PAUSE,
|
||||
"state": STATUS_PAUSED,
|
||||
"event": EVENT_TIMER_PAUSED,
|
||||
"data": {},
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_PAUSED,
|
||||
"expected_extra_attributes": {ATTR_REMAINING: "0:00:10"},
|
||||
"expected_event": EVENT_TIMER_PAUSED,
|
||||
},
|
||||
{
|
||||
"call": SERVICE_CANCEL,
|
||||
"state": STATUS_IDLE,
|
||||
"event": EVENT_TIMER_CANCELLED,
|
||||
"data": {},
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_IDLE,
|
||||
"expected_extra_attributes": {},
|
||||
"expected_event": EVENT_TIMER_CANCELLED,
|
||||
},
|
||||
{
|
||||
"call": SERVICE_START,
|
||||
"state": STATUS_ACTIVE,
|
||||
"event": EVENT_TIMER_STARTED,
|
||||
"data": {},
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_ACTIVE,
|
||||
"expected_extra_attributes": {
|
||||
ATTR_FINISHES_AT: finish_10,
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
},
|
||||
"expected_event": EVENT_TIMER_STARTED,
|
||||
},
|
||||
{
|
||||
"call": SERVICE_CHANGE,
|
||||
"state": STATUS_ACTIVE,
|
||||
"event": EVENT_TIMER_CHANGED,
|
||||
"data": {CONF_DURATION: -5},
|
||||
"call_data": {CONF_DURATION: -5},
|
||||
"expected_state": STATUS_ACTIVE,
|
||||
"expected_extra_attributes": {
|
||||
ATTR_FINISHES_AT: finish_5,
|
||||
ATTR_REMAINING: "0:00:05",
|
||||
},
|
||||
"expected_event": EVENT_TIMER_CHANGED,
|
||||
},
|
||||
{
|
||||
"call": SERVICE_START,
|
||||
"state": STATUS_ACTIVE,
|
||||
"event": EVENT_TIMER_RESTARTED,
|
||||
"data": {},
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_ACTIVE,
|
||||
"expected_extra_attributes": {
|
||||
ATTR_FINISHES_AT: finish_5,
|
||||
ATTR_REMAINING: "0:00:05",
|
||||
},
|
||||
"expected_event": EVENT_TIMER_RESTARTED,
|
||||
},
|
||||
{
|
||||
"call": SERVICE_PAUSE,
|
||||
"state": STATUS_PAUSED,
|
||||
"event": EVENT_TIMER_PAUSED,
|
||||
"data": {},
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_PAUSED,
|
||||
"expected_extra_attributes": {ATTR_REMAINING: "0:00:05"},
|
||||
"expected_event": EVENT_TIMER_PAUSED,
|
||||
},
|
||||
{
|
||||
"call": SERVICE_FINISH,
|
||||
"state": STATUS_IDLE,
|
||||
"event": EVENT_TIMER_FINISHED,
|
||||
"data": {},
|
||||
"call_data": {},
|
||||
"expected_state": STATUS_IDLE,
|
||||
"expected_extra_attributes": {},
|
||||
"expected_event": EVENT_TIMER_FINISHED,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -275,22 +326,38 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
step["call"],
|
||||
{CONF_ENTITY_ID: "timer.test1", **step["data"]},
|
||||
{CONF_ENTITY_ID: "timer.test1", **step["call_data"]},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
if step["state"] is not None:
|
||||
assert state.state == step["state"]
|
||||
if step["expected_state"] is not None:
|
||||
assert state.state == step["expected_state"]
|
||||
assert (
|
||||
state.attributes
|
||||
== {
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
}
|
||||
| step["expected_extra_attributes"]
|
||||
)
|
||||
|
||||
if step["event"] is not None:
|
||||
if step["expected_event"] is not None:
|
||||
expected_events += 1
|
||||
last_result = results[-1]
|
||||
event, state = last_result
|
||||
assert event.event_type == step["event"]
|
||||
assert state == step["state"]
|
||||
assert event.event_type == step["expected_event"]
|
||||
assert state.state == step["expected_state"]
|
||||
assert (
|
||||
state.attributes
|
||||
== {
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
}
|
||||
| step["expected_extra_attributes"]
|
||||
)
|
||||
assert len(results) == expected_events
|
||||
|
||||
|
||||
@@ -302,7 +369,10 @@ async def test_start_service(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_IDLE
|
||||
assert state.attributes[ATTR_DURATION] == "0:00:10"
|
||||
assert state.attributes == {
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_DURATION: "0:00:10",
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
|
||||
@@ -311,8 +381,12 @@ async def test_start_service(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_ACTIVE
|
||||
assert state.attributes[ATTR_DURATION] == "0:00:10"
|
||||
assert state.attributes[ATTR_REMAINING] == "0:00:10"
|
||||
assert state.attributes == {
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN, SERVICE_CANCEL, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
|
||||
@@ -321,8 +395,10 @@ async def test_start_service(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_IDLE
|
||||
assert state.attributes[ATTR_DURATION] == "0:00:10"
|
||||
assert ATTR_REMAINING not in state.attributes
|
||||
assert state.attributes == {
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_DURATION: "0:00:10",
|
||||
}
|
||||
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await hass.services.async_call(
|
||||
@@ -342,8 +418,12 @@ async def test_start_service(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_ACTIVE
|
||||
assert state.attributes[ATTR_DURATION] == "0:00:15"
|
||||
assert state.attributes[ATTR_REMAINING] == "0:00:15"
|
||||
assert state.attributes == {
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_DURATION: "0:00:15",
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=15)).isoformat(),
|
||||
ATTR_REMAINING: "0:00:15",
|
||||
}
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
@@ -376,8 +456,12 @@ async def test_start_service(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_ACTIVE
|
||||
assert state.attributes[ATTR_DURATION] == "0:00:15"
|
||||
assert state.attributes[ATTR_REMAINING] == "0:00:12"
|
||||
assert state.attributes == {
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_DURATION: "0:00:15",
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=12)).isoformat(),
|
||||
ATTR_REMAINING: "0:00:12",
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
@@ -388,8 +472,12 @@ async def test_start_service(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_ACTIVE
|
||||
assert state.attributes[ATTR_DURATION] == "0:00:15"
|
||||
assert state.attributes[ATTR_REMAINING] == "0:00:14"
|
||||
assert state.attributes == {
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_DURATION: "0:00:15",
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=14)).isoformat(),
|
||||
ATTR_REMAINING: "0:00:14",
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN, SERVICE_CANCEL, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
|
||||
@@ -398,8 +486,10 @@ async def test_start_service(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_IDLE
|
||||
assert state.attributes[ATTR_DURATION] == "0:00:10"
|
||||
assert ATTR_REMAINING not in state.attributes
|
||||
assert state.attributes == {
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_DURATION: "0:00:10",
|
||||
}
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
@@ -415,11 +505,16 @@ async def test_start_service(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_IDLE
|
||||
assert state.attributes[ATTR_DURATION] == "0:00:10"
|
||||
assert ATTR_REMAINING not in state.attributes
|
||||
assert state.attributes == {
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_DURATION: "0:00:10",
|
||||
}
|
||||
|
||||
|
||||
async def test_wait_till_timer_expires(hass: HomeAssistant) -> None:
|
||||
@pytest.mark.freeze_time("2023-06-05 17:47:50")
|
||||
async def test_wait_till_timer_expires(
|
||||
hass: HomeAssistant, freezer: FrozenDateTimeFactory
|
||||
) -> None:
|
||||
"""Test for a timer to end."""
|
||||
hass.set_state(CoreState.starting)
|
||||
|
||||
@@ -428,6 +523,10 @@ async def test_wait_till_timer_expires(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_IDLE
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:20",
|
||||
ATTR_EDITABLE: False,
|
||||
}
|
||||
|
||||
results = []
|
||||
|
||||
@@ -450,6 +549,12 @@ async def test_wait_till_timer_expires(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_ACTIVE
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:20",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=20)).isoformat(),
|
||||
ATTR_REMAINING: "0:00:20",
|
||||
}
|
||||
|
||||
assert results[-1].event_type == EVENT_TIMER_STARTED
|
||||
assert len(results) == 1
|
||||
@@ -465,23 +570,41 @@ async def test_wait_till_timer_expires(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_ACTIVE
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:20",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=15)).isoformat(),
|
||||
ATTR_REMAINING: "0:00:15",
|
||||
}
|
||||
|
||||
assert results[-1].event_type == EVENT_TIMER_CHANGED
|
||||
assert len(results) == 2
|
||||
|
||||
async_fire_time_changed(hass, utcnow() + timedelta(seconds=10))
|
||||
freezer.tick(10)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_ACTIVE
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:20",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=5)).isoformat(),
|
||||
ATTR_REMAINING: "0:00:15",
|
||||
}
|
||||
|
||||
async_fire_time_changed(hass, utcnow() + timedelta(seconds=20))
|
||||
freezer.tick(20)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_IDLE
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:20",
|
||||
ATTR_EDITABLE: False,
|
||||
}
|
||||
|
||||
assert results[-1].event_type == EVENT_TIMER_FINISHED
|
||||
assert len(results) == 3
|
||||
@@ -496,6 +619,10 @@ async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> Non
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_IDLE
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
}
|
||||
|
||||
|
||||
async def test_config_reload(
|
||||
@@ -538,13 +665,18 @@ async def test_config_reload(
|
||||
assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None
|
||||
|
||||
assert state_1.state == STATUS_IDLE
|
||||
assert ATTR_ICON not in state_1.attributes
|
||||
assert ATTR_FRIENDLY_NAME not in state_1.attributes
|
||||
assert state_1.attributes == {
|
||||
ATTR_DURATION: "0:00:00",
|
||||
ATTR_EDITABLE: False,
|
||||
}
|
||||
|
||||
assert state_2.state == STATUS_IDLE
|
||||
assert state_2.attributes.get(ATTR_FRIENDLY_NAME) == "Hello World"
|
||||
assert state_2.attributes.get(ATTR_ICON) == "mdi:work"
|
||||
assert state_2.attributes.get(ATTR_DURATION) == "0:00:10"
|
||||
assert state_2.attributes == {
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FRIENDLY_NAME: "Hello World",
|
||||
ATTR_ICON: "mdi:work",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"homeassistant.config.load_yaml_config_file",
|
||||
@@ -589,15 +721,21 @@ async def test_config_reload(
|
||||
assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None
|
||||
|
||||
assert state_2.state == STATUS_IDLE
|
||||
assert state_2.attributes.get(ATTR_FRIENDLY_NAME) == "Hello World reloaded"
|
||||
assert state_2.attributes.get(ATTR_ICON) == "mdi:work-reloaded"
|
||||
assert state_2.attributes.get(ATTR_DURATION) == "0:00:20"
|
||||
assert state_2.attributes == {
|
||||
ATTR_DURATION: "0:00:20",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FRIENDLY_NAME: "Hello World reloaded",
|
||||
ATTR_ICON: "mdi:work-reloaded",
|
||||
}
|
||||
|
||||
assert state_3.state == STATUS_IDLE
|
||||
assert ATTR_ICON not in state_3.attributes
|
||||
assert ATTR_FRIENDLY_NAME not in state_3.attributes
|
||||
assert state_3.attributes == {
|
||||
ATTR_DURATION: "0:00:00",
|
||||
ATTR_EDITABLE: False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2023-06-05 17:47:50")
|
||||
async def test_timer_restarted_event(hass: HomeAssistant) -> None:
|
||||
"""Ensure restarted event is called after starting a paused or running timer."""
|
||||
hass.set_state(CoreState.starting)
|
||||
@@ -607,6 +745,10 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_IDLE
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
}
|
||||
|
||||
results = []
|
||||
|
||||
@@ -628,6 +770,12 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_ACTIVE
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
}
|
||||
|
||||
assert results[-1].event_type == EVENT_TIMER_STARTED
|
||||
assert len(results) == 1
|
||||
@@ -639,6 +787,12 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_ACTIVE
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
}
|
||||
|
||||
assert results[-1].event_type == EVENT_TIMER_RESTARTED
|
||||
assert len(results) == 2
|
||||
@@ -650,6 +804,11 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_PAUSED
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
}
|
||||
|
||||
assert results[-1].event_type == EVENT_TIMER_PAUSED
|
||||
assert len(results) == 3
|
||||
@@ -661,11 +820,18 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_ACTIVE
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
}
|
||||
|
||||
assert results[-1].event_type == EVENT_TIMER_RESTARTED
|
||||
assert len(results) == 4
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2023-06-05 17:47:50")
|
||||
async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
|
||||
"""Ensure timer's state changes when it restarted."""
|
||||
hass.set_state(CoreState.starting)
|
||||
@@ -675,6 +841,10 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_IDLE
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
}
|
||||
|
||||
results = []
|
||||
|
||||
@@ -692,6 +862,12 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_ACTIVE
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
}
|
||||
|
||||
assert results[-1].event_type == EVENT_STATE_CHANGED
|
||||
assert len(results) == 1
|
||||
@@ -703,6 +879,12 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get("timer.test1")
|
||||
assert state
|
||||
assert state.state == STATUS_ACTIVE
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:10",
|
||||
ATTR_EDITABLE: False,
|
||||
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
|
||||
ATTR_REMAINING: "0:00:10",
|
||||
}
|
||||
|
||||
assert results[-1].event_type == EVENT_STATE_CHANGED
|
||||
assert len(results) == 2
|
||||
@@ -713,8 +895,11 @@ async def test_load_from_storage(hass: HomeAssistant, storage_setup) -> None:
|
||||
assert await storage_setup()
|
||||
state = hass.states.get(f"{DOMAIN}.timer_from_storage")
|
||||
assert state.state == STATUS_IDLE
|
||||
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "timer from storage"
|
||||
assert state.attributes.get(ATTR_EDITABLE)
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:00",
|
||||
ATTR_EDITABLE: True,
|
||||
ATTR_FRIENDLY_NAME: "timer from storage",
|
||||
}
|
||||
|
||||
|
||||
async def test_editable_state_attribute(hass: HomeAssistant, storage_setup) -> None:
|
||||
@@ -723,12 +908,18 @@ async def test_editable_state_attribute(hass: HomeAssistant, storage_setup) -> N
|
||||
|
||||
state = hass.states.get(f"{DOMAIN}.{DOMAIN}_from_storage")
|
||||
assert state.state == STATUS_IDLE
|
||||
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "timer from storage"
|
||||
assert state.attributes.get(ATTR_EDITABLE)
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:00",
|
||||
ATTR_EDITABLE: True,
|
||||
ATTR_FRIENDLY_NAME: "timer from storage",
|
||||
}
|
||||
|
||||
state = hass.states.get(f"{DOMAIN}.from_yaml")
|
||||
assert not state.attributes.get(ATTR_EDITABLE)
|
||||
assert state.state == STATUS_IDLE
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:00",
|
||||
ATTR_EDITABLE: False,
|
||||
}
|
||||
|
||||
|
||||
async def test_ws_list(
|
||||
@@ -797,7 +988,12 @@ async def test_update(
|
||||
timer_entity_id = f"{DOMAIN}.{DOMAIN}_{timer_id}"
|
||||
|
||||
state = hass.states.get(timer_entity_id)
|
||||
assert state.attributes[ATTR_FRIENDLY_NAME] == "timer from storage"
|
||||
assert state.state == STATUS_IDLE
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:00",
|
||||
ATTR_EDITABLE: True,
|
||||
ATTR_FRIENDLY_NAME: "timer from storage",
|
||||
}
|
||||
assert (
|
||||
entity_registry.async_get_entity_id(DOMAIN, DOMAIN, timer_id) == timer_entity_id
|
||||
)
|
||||
@@ -827,8 +1023,13 @@ async def test_update(
|
||||
}
|
||||
|
||||
state = hass.states.get(timer_entity_id)
|
||||
assert state.attributes[ATTR_DURATION] == _format_timedelta(cv.time_period(33))
|
||||
assert state.attributes[ATTR_RESTORE]
|
||||
assert state.state == STATUS_IDLE
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:33",
|
||||
ATTR_EDITABLE: True,
|
||||
ATTR_FRIENDLY_NAME: "timer from storage",
|
||||
ATTR_RESTORE: True,
|
||||
}
|
||||
|
||||
|
||||
async def test_ws_create(
|
||||
@@ -862,7 +1063,11 @@ async def test_ws_create(
|
||||
|
||||
state = hass.states.get(timer_entity_id)
|
||||
assert state.state == STATUS_IDLE
|
||||
assert state.attributes[ATTR_DURATION] == _format_timedelta(cv.time_period(42))
|
||||
assert state.attributes == {
|
||||
ATTR_DURATION: "0:00:42",
|
||||
ATTR_EDITABLE: True,
|
||||
ATTR_FRIENDLY_NAME: "New Timer",
|
||||
}
|
||||
assert (
|
||||
entity_registry.async_get_entity_id(DOMAIN, DOMAIN, timer_id) == timer_entity_id
|
||||
)
|
||||
@@ -919,10 +1124,12 @@ async def test_restore_paused(hass: HomeAssistant) -> None:
|
||||
await entity.async_added_to_hass()
|
||||
await hass.async_block_till_done()
|
||||
assert entity.state == STATUS_PAUSED
|
||||
assert entity.extra_state_attributes[ATTR_DURATION] == "0:00:30"
|
||||
assert entity.extra_state_attributes[ATTR_REMAINING] == "0:00:15"
|
||||
assert ATTR_FINISHES_AT not in entity.extra_state_attributes
|
||||
assert entity.extra_state_attributes[ATTR_RESTORE]
|
||||
assert entity.extra_state_attributes == {
|
||||
ATTR_DURATION: "0:00:30",
|
||||
ATTR_EDITABLE: True,
|
||||
ATTR_REMAINING: "0:00:15",
|
||||
ATTR_RESTORE: True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2023-06-05 17:47:50")
|
||||
@@ -967,10 +1174,13 @@ async def test_restore_active_resume(hass: HomeAssistant) -> None:
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entity.state == STATUS_ACTIVE
|
||||
assert entity.extra_state_attributes[ATTR_DURATION] == "0:00:30"
|
||||
assert entity.extra_state_attributes[ATTR_REMAINING] == "0:00:15"
|
||||
assert entity.extra_state_attributes[ATTR_FINISHES_AT] == finish.isoformat()
|
||||
assert entity.extra_state_attributes[ATTR_RESTORE]
|
||||
assert entity.extra_state_attributes == {
|
||||
ATTR_DURATION: "0:00:30",
|
||||
ATTR_EDITABLE: True,
|
||||
ATTR_FINISHES_AT: finish.isoformat(),
|
||||
ATTR_REMAINING: "0:00:15",
|
||||
ATTR_RESTORE: True,
|
||||
}
|
||||
assert len(events) == 1
|
||||
|
||||
|
||||
@@ -1013,8 +1223,9 @@ async def test_restore_active_finished_outside_grace(hass: HomeAssistant) -> Non
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entity.state == STATUS_IDLE
|
||||
assert entity.extra_state_attributes[ATTR_DURATION] == "0:01:00"
|
||||
assert ATTR_REMAINING not in entity.extra_state_attributes
|
||||
assert ATTR_FINISHES_AT not in entity.extra_state_attributes
|
||||
assert entity.extra_state_attributes[ATTR_RESTORE]
|
||||
assert entity.extra_state_attributes == {
|
||||
ATTR_DURATION: "0:01:00",
|
||||
ATTR_EDITABLE: True,
|
||||
ATTR_RESTORE: True,
|
||||
}
|
||||
assert len(events) == 1
|
||||
|
||||
129
tests/components/update/test_condition.py
Normal file
129
tests/components/update/test_condition.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Test update conditions."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.components.common import (
|
||||
ConditionStateDescription,
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_updates(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple update entities associated with different targets."""
|
||||
return await target_entities(hass, "update", domain_excluded="switch")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"condition",
|
||||
[
|
||||
"update.is_available",
|
||||
"update.is_not_available",
|
||||
],
|
||||
)
|
||||
async def test_update_conditions_gated_by_labs_flag(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str
|
||||
) -> None:
|
||||
"""Test the update conditions are gated by the labs flag."""
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("update"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("condition", "condition_options", "states"),
|
||||
[
|
||||
*parametrize_condition_states_any(
|
||||
condition="update.is_available",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
excluded_entities_from_other_domain=True,
|
||||
),
|
||||
*parametrize_condition_states_any(
|
||||
condition="update.is_not_available",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
excluded_entities_from_other_domain=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_update_state_condition_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
target_updates: dict[str, list[str]],
|
||||
condition_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
condition: str,
|
||||
condition_options: dict[str, Any],
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the update state condition with the 'any' behavior."""
|
||||
await assert_condition_behavior_any(
|
||||
hass,
|
||||
target_entities=target_updates,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("update"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("condition", "condition_options", "states"),
|
||||
[
|
||||
*parametrize_condition_states_all(
|
||||
condition="update.is_available",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
excluded_entities_from_other_domain=True,
|
||||
),
|
||||
*parametrize_condition_states_all(
|
||||
condition="update.is_not_available",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
excluded_entities_from_other_domain=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_update_state_condition_behavior_all(
|
||||
hass: HomeAssistant,
|
||||
target_updates: dict[str, list[str]],
|
||||
condition_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
condition: str,
|
||||
condition_options: dict[str, Any],
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the update state condition with the 'all' behavior."""
|
||||
await assert_condition_behavior_all(
|
||||
hass,
|
||||
target_entities=target_updates,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
from collections.abc import Mapping
|
||||
from contextlib import AbstractContextManager, nullcontext as does_not_raise
|
||||
import datetime
|
||||
import io
|
||||
import logging
|
||||
from typing import Any
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from pytest_unordered import unordered
|
||||
import voluptuous as vol
|
||||
@@ -19,9 +22,12 @@ from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_FOR,
|
||||
CONF_OPTIONS,
|
||||
CONF_PLATFORM,
|
||||
CONF_TARGET,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
UnitOfTemperature,
|
||||
@@ -41,6 +47,10 @@ from homeassistant.helpers.automation import (
|
||||
move_top_level_schema_fields_to_options,
|
||||
)
|
||||
from homeassistant.helpers.trigger import (
|
||||
ATTR_BEHAVIOR,
|
||||
BEHAVIOR_ANY,
|
||||
BEHAVIOR_FIRST,
|
||||
BEHAVIOR_LAST,
|
||||
DATA_PLUGGABLE_ACTIONS,
|
||||
TRIGGERS,
|
||||
EntityNumericalStateChangedTriggerWithUnitBase,
|
||||
@@ -65,7 +75,13 @@ from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util.unit_conversion import TemperatureConverter
|
||||
from homeassistant.util.yaml.loader import parse_yaml
|
||||
|
||||
from tests.common import MockModule, MockPlatform, mock_integration, mock_platform
|
||||
from tests.common import (
|
||||
MockModule,
|
||||
MockPlatform,
|
||||
async_fire_time_changed,
|
||||
mock_integration,
|
||||
mock_platform,
|
||||
)
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
|
||||
@@ -3080,3 +3096,787 @@ async def test_make_entity_origin_state_trigger(
|
||||
|
||||
# To-state still matches from_state — not valid
|
||||
assert not trig.is_valid_state(from_state)
|
||||
|
||||
|
||||
class _OffToOnTrigger(EntityTriggerBase):
|
||||
"""Test trigger that fires when state becomes 'on'."""
|
||||
|
||||
_domain_specs = {"test": DomainSpec()}
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Valid if transitioning from a non-'on' state."""
|
||||
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
return False
|
||||
return from_state.state != STATE_ON
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Valid if the state is 'on'."""
|
||||
return state.state == STATE_ON
|
||||
|
||||
|
||||
async def _arm_off_to_on_trigger(
|
||||
hass: HomeAssistant,
|
||||
entity_ids: list[str],
|
||||
behavior: str,
|
||||
calls: list[dict[str, Any]],
|
||||
duration: dict[str, int] | None,
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Set up _OffToOnTrigger via async_initialize_triggers."""
|
||||
|
||||
async def async_get_triggers(
|
||||
hass: HomeAssistant,
|
||||
) -> dict[str, type[Trigger]]:
|
||||
return {"off_to_on": _OffToOnTrigger}
|
||||
|
||||
mock_integration(hass, MockModule("test"))
|
||||
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
|
||||
|
||||
options: dict[str, Any] = {ATTR_BEHAVIOR: behavior}
|
||||
if duration is not None:
|
||||
options[CONF_FOR] = duration
|
||||
|
||||
trigger_config = {
|
||||
CONF_PLATFORM: "test.off_to_on",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: entity_ids},
|
||||
CONF_OPTIONS: options,
|
||||
}
|
||||
|
||||
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_off_to_on",
|
||||
log_cb=log.log,
|
||||
)
|
||||
|
||||
|
||||
def _set_or_remove_state(
|
||||
hass: HomeAssistant, entity_id: str, state: str | None
|
||||
) -> None:
|
||||
"""Set or remove state based on whether state is None."""
|
||||
if state is None:
|
||||
hass.states.async_remove(entity_id)
|
||||
else:
|
||||
hass.states.async_set(entity_id, state)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("behavior", [BEHAVIOR_ANY, BEHAVIOR_FIRST, BEHAVIOR_LAST])
|
||||
async def test_entity_trigger_fires_on_valid_transition(
|
||||
hass: HomeAssistant, behavior: str
|
||||
) -> None:
|
||||
"""Test EntityTriggerBase fires immediately on a valid off→on transition without duration."""
|
||||
entity_id = "test.entity_1"
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_id], behavior, calls, duration=None
|
||||
)
|
||||
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_id
|
||||
assert calls[0]["from_state"].state == STATE_OFF
|
||||
assert calls[0]["to_state"].state == STATE_ON
|
||||
assert calls[0]["for"] is None
|
||||
|
||||
# Transition back and trigger again
|
||||
calls.clear()
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("behavior", [BEHAVIOR_ANY, BEHAVIOR_FIRST, BEHAVIOR_LAST])
|
||||
@pytest.mark.parametrize(
|
||||
"initial_state",
|
||||
[STATE_UNAVAILABLE, STATE_UNKNOWN, None],
|
||||
ids=["unavailable", "unknown", "no_state"],
|
||||
)
|
||||
async def test_entity_trigger_from_invalid_initial_state(
|
||||
hass: HomeAssistant, behavior: str, initial_state: str | None
|
||||
) -> None:
|
||||
"""Test that the trigger does not fire when transitioning from unavailable, unknown, or no state."""
|
||||
entity_id = "test.entity_1"
|
||||
_set_or_remove_state(hass, entity_id, initial_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_id], behavior, calls, duration=None
|
||||
)
|
||||
|
||||
# Transition to "on" from the invalid initial state
|
||||
_set_or_remove_state(hass, entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Should NOT fire — transition from invalid state is rejected
|
||||
assert len(calls) == 0
|
||||
|
||||
# Now transition back to off and then to on — should fire
|
||||
_set_or_remove_state(hass, entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
_set_or_remove_state(hass, entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_last_requires_all(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test behavior last: trigger fires only when ALL entities are on."""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
hass.states.async_set(entity_b, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_a, entity_b], BEHAVIOR_LAST, calls, duration=None
|
||||
)
|
||||
|
||||
# Turn only A on — not all match, should not fire
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# Turn B on — now all match, should fire
|
||||
hass.states.async_set(entity_b, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_first_requires_exactly_one(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test behavior first: trigger fires only when exactly one entity matches."""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
hass.states.async_set(entity_b, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_a, entity_b], BEHAVIOR_FIRST, calls, duration=None
|
||||
)
|
||||
|
||||
# Turn A on — exactly one matches, should fire
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
# Turn B on — now two match, B's transition should NOT fire
|
||||
hass.states.async_set(entity_b, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_state",
|
||||
[STATE_UNAVAILABLE, STATE_UNKNOWN],
|
||||
ids=["unavailable", "unknown"],
|
||||
)
|
||||
async def test_entity_trigger_last_ignores_unavailable_and_unknown_entity(
|
||||
hass: HomeAssistant, invalid_state: str
|
||||
) -> None:
|
||||
"""Test behavior last: unavailable/unknown entities are excluded from check_all_match.
|
||||
|
||||
With three entities (A=off, B=unavailable, C=off), turning A on should
|
||||
not fire because C is still off, so the available entities do not all
|
||||
match. Turning C on then fires because all *available* entities (A and C)
|
||||
match. Without the exclusion, B would fail the "all match" check.
|
||||
"""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
entity_c = "test.entity_c"
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
hass.states.async_set(entity_b, invalid_state)
|
||||
hass.states.async_set(entity_c, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_a, entity_b, entity_c], BEHAVIOR_LAST, calls, duration=None
|
||||
)
|
||||
|
||||
# Turn A on — B is unavailable and skipped, only A is on → all doesn't match
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# Turn C on — B is unavailable and skipped, A and C are both on → all match
|
||||
hass.states.async_set(entity_c, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_c
|
||||
|
||||
# B recovers to off — now not all available entities match, so
|
||||
# turning A off→on should NOT fire
|
||||
calls.clear()
|
||||
hass.states.async_set(entity_b, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_state",
|
||||
[STATE_UNAVAILABLE, STATE_UNKNOWN],
|
||||
ids=["unavailable", "unknown"],
|
||||
)
|
||||
async def test_entity_trigger_first_ignores_unavailable_and_unknown_entity(
|
||||
hass: HomeAssistant, invalid_state: str
|
||||
) -> None:
|
||||
"""Test behavior first: unavailable/unknown entities are excluded from check_one_match.
|
||||
|
||||
With three entities (A=off, B=unavailable, C=off), turning A on should
|
||||
fire because exactly one *available* entity matches. B is skipped.
|
||||
Then turning C on should NOT fire because now two available entities match.
|
||||
"""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
entity_c = "test.entity_c"
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
hass.states.async_set(entity_b, invalid_state)
|
||||
hass.states.async_set(entity_c, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_a, entity_b, entity_c], BEHAVIOR_FIRST, calls, duration=None
|
||||
)
|
||||
|
||||
# Turn A on — B is unavailable and skipped, only A matches → exactly one
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_a
|
||||
|
||||
# Turn C on — now two available entities match (A and C), should NOT fire
|
||||
hass.states.async_set(entity_c, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("behavior", [BEHAVIOR_ANY, BEHAVIOR_FIRST, BEHAVIOR_LAST])
|
||||
async def test_entity_trigger_with_duration(
|
||||
hass: HomeAssistant, freezer: FrozenDateTimeFactory, behavior: str
|
||||
) -> None:
|
||||
"""Test EntityTriggerBase waits for duration before firing."""
|
||||
entity_id = "test.entity_1"
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_id], behavior, calls, duration={"seconds": 5}
|
||||
)
|
||||
|
||||
# Turn on — should NOT fire immediately
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# Advance time past duration — should fire
|
||||
freezer.tick(datetime.timedelta(seconds=6))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_id
|
||||
assert calls[0]["from_state"].state == STATE_OFF
|
||||
assert calls[0]["to_state"].state == STATE_ON
|
||||
assert calls[0]["for"] == datetime.timedelta(seconds=5)
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("behavior", [BEHAVIOR_ANY, BEHAVIOR_FIRST, BEHAVIOR_LAST])
|
||||
async def test_entity_trigger_duration_cancelled_on_state_change(
|
||||
hass: HomeAssistant, freezer: FrozenDateTimeFactory, behavior: str
|
||||
) -> None:
|
||||
"""Test that the duration timer is cancelled if state changes back."""
|
||||
entity_id = "test.entity_1"
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_id], behavior, calls, duration={"seconds": 5}
|
||||
)
|
||||
|
||||
# Turn on, then back off before duration expires
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance past the original duration — should NOT fire
|
||||
freezer.tick(datetime.timedelta(seconds=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_duration_any_independent(
|
||||
hass: HomeAssistant, freezer: FrozenDateTimeFactory
|
||||
) -> None:
|
||||
"""Test behavior any tracks per-entity durations independently."""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
hass.states.async_set(entity_b, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_a, entity_b], BEHAVIOR_ANY, calls, duration={"seconds": 5}
|
||||
)
|
||||
|
||||
# Turn A on
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# Turn B on 2 seconds later
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
hass.states.async_set(entity_b, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# After 5s from A's turn-on, A should fire
|
||||
freezer.tick(datetime.timedelta(seconds=3))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_a
|
||||
|
||||
# After 5s from B's turn-on (2 more seconds), B should fire
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 2
|
||||
assert calls[1]["entity_id"] == entity_b
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_duration_any_entity_off_cancels_only_that_entity(
|
||||
hass: HomeAssistant, freezer: FrozenDateTimeFactory
|
||||
) -> None:
|
||||
"""Test behavior any: turning off one entity doesn't cancel the other's timer."""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
hass.states.async_set(entity_b, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_a, entity_b], BEHAVIOR_ANY, calls, duration={"seconds": 5}
|
||||
)
|
||||
|
||||
# Turn both on
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
hass.states.async_set(entity_b, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Turn A off after 2 seconds — cancels A's timer but not B's
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# After 5s total, B should fire but A should not
|
||||
freezer.tick(datetime.timedelta(seconds=3))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_b
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_duration_last_requires_all(
|
||||
hass: HomeAssistant, freezer: FrozenDateTimeFactory
|
||||
) -> None:
|
||||
"""Test behavior last: trigger fires only when ALL entities are on for duration."""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
hass.states.async_set(entity_b, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_a, entity_b], BEHAVIOR_LAST, calls, duration={"seconds": 5}
|
||||
)
|
||||
|
||||
# Turn only A on — should not start timer (not all match)
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
freezer.tick(datetime.timedelta(seconds=6))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# Turn B on — now all match, timer starts
|
||||
hass.states.async_set(entity_b, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
freezer.tick(datetime.timedelta(seconds=6))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_duration_last_cancelled_when_one_turns_off(
|
||||
hass: HomeAssistant, freezer: FrozenDateTimeFactory
|
||||
) -> None:
|
||||
"""Test behavior last: timer is cancelled when one entity turns off."""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
hass.states.async_set(entity_b, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_a, entity_b], BEHAVIOR_LAST, calls, duration={"seconds": 5}
|
||||
)
|
||||
|
||||
# Turn both on
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
hass.states.async_set(entity_b, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Turn A off after 2 seconds
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance past original duration — should NOT fire
|
||||
freezer.tick(datetime.timedelta(seconds=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_duration_last_timer_reset(
|
||||
hass: HomeAssistant, freezer: FrozenDateTimeFactory
|
||||
) -> None:
|
||||
"""Test behavior last: timer resets when combined state goes off and back on."""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
hass.states.async_set(entity_b, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_a, entity_b], BEHAVIOR_LAST, calls, duration={"seconds": 5}
|
||||
)
|
||||
|
||||
# Turn both on — combined state "all on", timer starts
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(entity_b, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# After 2 seconds, B turns off — combined state breaks, timer cancelled
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
hass.states.async_set(entity_b, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# B turns back on — combined state restored, timer restarts
|
||||
freezer.tick(datetime.timedelta(seconds=1))
|
||||
async_fire_time_changed(hass)
|
||||
hass.states.async_set(entity_b, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# 4 seconds after restart (not enough) — should NOT fire
|
||||
freezer.tick(datetime.timedelta(seconds=4))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# 1 more second (5 total from restart) — should fire
|
||||
freezer.tick(datetime.timedelta(seconds=1))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_duration_first_fires_when_any_on(
|
||||
hass: HomeAssistant, freezer: FrozenDateTimeFactory
|
||||
) -> None:
|
||||
"""Test behavior first: trigger fires when first entity turns on for duration."""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
hass.states.async_set(entity_b, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_a, entity_b], BEHAVIOR_FIRST, calls, duration={"seconds": 5}
|
||||
)
|
||||
|
||||
# Turn A on — combined state goes to "at least one on", timer starts
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# Advance past duration — should fire
|
||||
freezer.tick(datetime.timedelta(seconds=6))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_duration_first_not_cancelled_by_second(
|
||||
hass: HomeAssistant, freezer: FrozenDateTimeFactory
|
||||
) -> None:
|
||||
"""Test behavior first: second entity turning on doesn't restart timer."""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
hass.states.async_set(entity_b, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_a, entity_b], BEHAVIOR_FIRST, calls, duration={"seconds": 5}
|
||||
)
|
||||
|
||||
# Turn A on
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Turn B on 3 seconds later — combined state was already "any on",
|
||||
# so this should NOT restart the timer
|
||||
freezer.tick(datetime.timedelta(seconds=3))
|
||||
async_fire_time_changed(hass)
|
||||
hass.states.async_set(entity_b, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# 2 more seconds (5 total from A) — should fire
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_duration_first_not_cancelled_by_partial_off(
|
||||
hass: HomeAssistant, freezer: FrozenDateTimeFactory
|
||||
) -> None:
|
||||
"""Test behavior first: one entity off doesn't cancel if another is still on."""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
hass.states.async_set(entity_b, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_a, entity_b], BEHAVIOR_FIRST, calls, duration={"seconds": 5}
|
||||
)
|
||||
|
||||
# Turn both on
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(entity_b, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Turn A off after 2 seconds — combined state still "any on" (B is on)
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance past duration — should still fire (combined state never went to "none on")
|
||||
freezer.tick(datetime.timedelta(seconds=4))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_duration_first_cancelled_when_all_off(
|
||||
hass: HomeAssistant, freezer: FrozenDateTimeFactory
|
||||
) -> None:
|
||||
"""Test behavior first: timer cancelled when ALL entities turn off."""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
hass.states.async_set(entity_b, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_a, entity_b], BEHAVIOR_FIRST, calls, duration={"seconds": 5}
|
||||
)
|
||||
|
||||
# Turn both on
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
hass.states.async_set(entity_b, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Turn both off after 2 seconds — combined state goes to "none on"
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
hass.states.async_set(entity_b, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance past original duration — should NOT fire
|
||||
freezer.tick(datetime.timedelta(seconds=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_duration_any_retrigger_resets_timer(
|
||||
hass: HomeAssistant, freezer: FrozenDateTimeFactory
|
||||
) -> None:
|
||||
"""Test behavior any: turning an entity off and on resets its timer."""
|
||||
entity_id = "test.entity_1"
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_id], BEHAVIOR_ANY, calls, duration={"seconds": 5}
|
||||
)
|
||||
|
||||
# Turn on
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# After 3 seconds, turn off and on again — resets the timer
|
||||
freezer.tick(datetime.timedelta(seconds=3))
|
||||
async_fire_time_changed(hass)
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# 3 more seconds (6 from start, but only 3 from retrigger) — should NOT fire
|
||||
freezer.tick(datetime.timedelta(seconds=3))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# 2 more seconds (5 from retrigger) — should fire
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("behavior", "expected_calls"),
|
||||
[(BEHAVIOR_ANY, 0), (BEHAVIOR_FIRST, 0), (BEHAVIOR_LAST, 1)],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_state",
|
||||
[STATE_UNAVAILABLE, STATE_UNKNOWN, None],
|
||||
ids=["unavailable", "unknown", "removed"],
|
||||
)
|
||||
async def test_entity_trigger_duration_cancelled_on_invalid_state(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
behavior: str,
|
||||
expected_calls: int,
|
||||
invalid_state: str | None,
|
||||
) -> None:
|
||||
"""Test if the duration timer is cancelled if entity becomes unavailable, unknown, or is removed.
|
||||
|
||||
This is expected to happen in first and any modes, but not in last mode.
|
||||
"""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
_set_or_remove_state(hass, entity_a, STATE_OFF)
|
||||
_set_or_remove_state(hass, entity_b, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass, [entity_a, entity_b], behavior, calls, duration={"seconds": 5}
|
||||
)
|
||||
|
||||
# Turn on the entities needed to start the timer
|
||||
_set_or_remove_state(hass, entity_a, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
if behavior == BEHAVIOR_LAST:
|
||||
_set_or_remove_state(hass, entity_b, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Entity A becomes invalid during the wait
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
_set_or_remove_state(hass, entity_a, invalid_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance past the original duration — should NOT fire
|
||||
freezer.tick(datetime.timedelta(seconds=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == expected_calls
|
||||
|
||||
unsub()
|
||||
|
||||
Reference in New Issue
Block a user