Compare commits

...

22 Commits

Author SHA1 Message Date
Erik
badefd9707 Include duration in trigger variables 2026-04-15 20:09:39 +02:00
Erik
219cfdc99b Add comment 2026-04-15 17:11:27 +02:00
Erik
3a28af68c4 Improve readability 2026-04-15 15:03:26 +02:00
Erik
3f11d9240c Adjust 2026-04-15 15:01:26 +02:00
Erik
c0eb70d1af Adjust after rebase 2026-04-15 14:55:02 +02:00
Erik
77c5f2e175 Add tests 2026-04-15 14:47:01 +02:00
Erik
995eafc811 Adjust comment 2026-04-15 14:38:05 +02:00
Erik
c9a2b7cb04 Change for to optional 2026-04-15 14:38:05 +02:00
Erik
0379e7c12f Add duration to state based entity triggers 2026-04-15 14:38:04 +02:00
Erik Montnemery
ab5ae33290 Exclude unavailable and unknown in trigger first and last checks (#168224)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-15 14:20:49 +02:00
renovate[bot]
c0bf9a2bd2 Update pytest-sugar to 1.1.1 (#168270)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 13:07:21 +02:00
Norbert Rittel
d862b999ae Capitalize "REST" abbreviation in scrape error messages (#168280) 2026-04-15 11:36:39 +02:00
Erik Montnemery
d6be6e8810 Improve timer tests (#168277) 2026-04-15 11:21:59 +02:00
Daniel Hjelseth Høyer
f397f4c908 Handle Tibber async_get_client failing (#168207)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-04-15 10:50:29 +02:00
G Johansson
d58e7862c0 Scrape sub config entry (#141389) 2026-04-15 09:59:12 +02:00
Erik Montnemery
84f57f9859 Deduplicate toggle entity condition tests (#168195) 2026-04-15 08:19:09 +02:00
Erik Montnemery
c6169ec8eb Add update conditions (#167751) 2026-04-15 08:03:51 +02:00
renovate[bot]
c47cecf350 Update SQLAlchemy to 2.0.49 (#168260) 2026-04-15 07:20:58 +02:00
renovate[bot]
e31f611901 Update pytest-cov to 7.1.0 (#168267) 2026-04-15 07:20:10 +02:00
renovate[bot]
bc36b1dda2 Update coverage to 7.13.5 (#168238) 2026-04-15 07:19:39 +02:00
renovate[bot]
b3967130f0 Update orjson to 3.11.8 (#168259) 2026-04-15 06:40:43 +02:00
renovate[bot]
2960db3d8e Update codespell (#168235) 2026-04-15 06:34:50 +02:00
44 changed files with 2595 additions and 1379 deletions

View File

@@ -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:

View File

@@ -152,6 +152,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
"text",
"timer",
"todo",
"update",
"vacuum",
"valve",
"water_heater",

View File

@@ -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"

View File

@@ -9,6 +9,11 @@
- first
- last
- any
for:
required: true
default: 00:00:00
selector:
duration:
detected:
fields: *trigger_common_fields

View File

@@ -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"

View File

@@ -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")
)

View File

@@ -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"
]

View File

@@ -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.

View File

@@ -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)

View File

@@ -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 {}
),
)

View File

@@ -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"

View File

@@ -0,0 +1,21 @@
{
"config": {
"step": {
"user": {
"sections": {
"advanced": "mdi:cog",
"auth": "mdi:lock"
}
}
}
},
"options": {
"step": {
"init": {
"sections": {
"advanced": "mdi:cog"
}
}
}
}
}

View File

@@ -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."""

View File

@@ -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%]"
}
}
}
}

View File

@@ -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()

View File

@@ -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"]
}

View File

@@ -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()

View 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

View 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

View File

@@ -1,4 +1,12 @@
{
"conditions": {
"is_available": {
"condition": "mdi:package-up"
},
"is_not_available": {
"condition": "mdi:package"
}
},
"entity_component": {
"_": {
"default": "mdi:package-up",

View File

@@ -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",

View File

@@ -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:

View File

@@ -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.

View File

@@ -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

View File

@@ -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
View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View File

@@ -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(

View File

@@ -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",

View File

@@ -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

View 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,
})
# ---

View File

@@ -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",
},
],
}

View File

@@ -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"

View File

@@ -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(

View File

@@ -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"]

View File

@@ -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(

View File

@@ -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

View 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,
)

View File

@@ -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()