diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index e9f3f3644d619..508603ec66e56 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.8.0", "xknxproject==3.8.2", - "knx-frontend==2025.8.21.181525" + "knx-frontend==2025.8.24.205840" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 3efcde7edb6e1..387a6e9e6dea5 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -21,7 +21,7 @@ from homeassistant.helpers.typing import UNDEFINED from homeassistant.util.ulid import ulid_now -from .const import DOMAIN, KNX_MODULE_KEY +from .const import DOMAIN, KNX_MODULE_KEY, SUPPORTED_PLATFORMS_UI from .storage.config_store import ConfigStoreException from .storage.const import CONF_DATA from .storage.entity_store_schema import ( @@ -44,7 +44,7 @@ async def register_panel(hass: HomeAssistant) -> None: """Register the KNX Panel and Websocket API.""" - websocket_api.async_register_command(hass, ws_info) + websocket_api.async_register_command(hass, ws_get_base_data) websocket_api.async_register_command(hass, ws_project_file_process) websocket_api.async_register_command(hass, ws_project_file_remove) websocket_api.async_register_command(hass, ws_group_monitor_info) @@ -156,12 +156,12 @@ def with_knx( @websocket_api.require_admin @websocket_api.websocket_command( { - vol.Required("type"): "knx/info", + vol.Required("type"): "knx/get_base_data", } ) @provide_knx @callback -def ws_info( +def ws_get_base_data( hass: HomeAssistant, knx: KNXModule, connection: websocket_api.ActiveConnection, @@ -176,14 +176,18 @@ def ws_info( "tool_version": project_info["tool_version"], "xknxproject_version": project_info["xknxproject_version"], } + connection_info = { + "version": knx.xknx.version, + "connected": knx.xknx.connection_manager.connected.is_set(), + "current_address": str(knx.xknx.current_address), + } connection.send_result( msg["id"], { - "version": knx.xknx.version, - "connected": knx.xknx.connection_manager.connected.is_set(), - "current_address": str(knx.xknx.current_address), - "project": _project_info, + "connection_info": connection_info, + "project_info": _project_info, + "supported_platforms": sorted(SUPPORTED_PLATFORMS_UI), }, ) @@ -206,10 +210,7 @@ async def ws_get_knx_project( knxproject = await knx.project.get_knxproject() connection.send_result( msg["id"], - { - "project_loaded": knx.project.loaded, - "knxproject": knxproject, - }, + knxproject, ) diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json index 1eb96b71f4a10..f7001a92b9dca 100644 --- a/homeassistant/components/lg_thinq/icons.json +++ b/homeassistant/components/lg_thinq/icons.json @@ -45,6 +45,9 @@ }, "display_light": { "default": "mdi:lightbulb-on-outline" + }, + "air_clean_operation_mode": { + "default": "mdi:air-filter" } }, "binary_sensor": { diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 1d48a065915d9..52b9ea4a34621 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -70,6 +70,9 @@ }, "display_light": { "name": "Lighting" + }, + "air_clean_operation_mode": { + "name": "[%key:component::lg_thinq::entity::climate::climate_air_conditioner::state_attributes::preset_mode::state::air_clean%]" } }, "binary_sensor": { diff --git a/homeassistant/components/lg_thinq/switch.py b/homeassistant/components/lg_thinq/switch.py index 06363140193ff..8ba680ca93dc5 100644 --- a/homeassistant/components/lg_thinq/switch.py +++ b/homeassistant/components/lg_thinq/switch.py @@ -31,6 +31,15 @@ class ThinQSwitchEntityDescription(SwitchEntityDescription): off_key: str | None = None +DRYER_OPERATION_SWITCH_DESC = ThinQSwitchEntityDescription( + key=ThinQProperty.DRYER_OPERATION_MODE, translation_key="operation_power" +) + +WASHER_OPERATION_SWITCH_DESC = ThinQSwitchEntityDescription( + key=ThinQProperty.WASHER_OPERATION_MODE, translation_key="operation_power" +) + + DEVICE_TYPE_SWITCH_MAP: dict[DeviceType, tuple[ThinQSwitchEntityDescription, ...]] = { DeviceType.AIR_CONDITIONER: ( ThinQSwitchEntityDescription( @@ -52,6 +61,13 @@ class ThinQSwitchEntityDescription(SwitchEntityDescription): off_key="false", entity_category=EntityCategory.CONFIG, ), + ThinQSwitchEntityDescription( + key=ThinQProperty.AIR_CLEAN_OPERATION_MODE, + translation_key=ThinQProperty.AIR_CLEAN_OPERATION_MODE, + on_key="on", + off_key="off", + entity_category=EntityCategory.CONFIG, + ), ), DeviceType.AIR_PURIFIER_FAN: ( ThinQSwitchEntityDescription( @@ -84,6 +100,13 @@ class ThinQSwitchEntityDescription(SwitchEntityDescription): translation_key="operation_power", ), ), + DeviceType.DISH_WASHER: ( + ThinQSwitchEntityDescription( + key=ThinQProperty.DISH_WASHER_OPERATION_MODE, + translation_key="operation_power", + ), + ), + DeviceType.DRYER: (DRYER_OPERATION_SWITCH_DESC,), DeviceType.HUMIDIFIER: ( ThinQSwitchEntityDescription( key=ThinQProperty.HUMIDIFIER_OPERATION_MODE, @@ -155,6 +178,27 @@ class ThinQSwitchEntityDescription(SwitchEntityDescription): entity_category=EntityCategory.CONFIG, ), ), + DeviceType.STYLER: ( + ThinQSwitchEntityDescription( + key=ThinQProperty.STYLER_OPERATION_MODE, translation_key="operation_power" + ), + ), + DeviceType.VENTILATOR: ( + ThinQSwitchEntityDescription( + key=ThinQProperty.VENTILATOR_OPERATION_MODE, + translation_key="operation_power", + entity_category=EntityCategory.CONFIG, + ), + ), + DeviceType.WASHCOMBO_MAIN: (WASHER_OPERATION_SWITCH_DESC,), + DeviceType.WASHCOMBO_MINI: (WASHER_OPERATION_SWITCH_DESC,), + DeviceType.WASHER: (WASHER_OPERATION_SWITCH_DESC,), + DeviceType.WASHTOWER: ( + DRYER_OPERATION_SWITCH_DESC, + WASHER_OPERATION_SWITCH_DESC, + ), + DeviceType.WASHTOWER_DRYER: (DRYER_OPERATION_SWITCH_DESC,), + DeviceType.WASHTOWER_WASHER: (WASHER_OPERATION_SWITCH_DESC,), DeviceType.WINE_CELLAR: ( ThinQSwitchEntityDescription( key=ThinQProperty.OPTIMAL_HUMIDITY, @@ -186,7 +230,8 @@ async def async_setup_entry( entities.extend( ThinQSwitchEntity(coordinator, description, property_id) for property_id in coordinator.api.get_active_idx( - description.key, ActiveMode.READ_WRITE + description.key, + ActiveMode.WRITABLE, ) ) diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index ea569f4e27707..63de3daaf15e1 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -242,7 +242,7 @@ def _get_suggested_id(self, info: dict) -> str: async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" self.SCHEMA(update_data) - return item | update_data + return {CONF_ID: item[CONF_ID]} | update_data async def _async_load_data(self) -> SerializedStorageCollection | None: """Load the data.""" diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 91d0c590e00a8..175aacf5d4c01 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -41,5 +41,5 @@ "iot_class": "local_push", "loggers": ["switchbot"], "quality_scale": "gold", - "requirements": ["PySwitchbot==0.68.4"] + "requirements": ["PySwitchbot==0.69.0"] } diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index a3311c355632e..092dbc9e41e80 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -17,6 +17,7 @@ ) from homeassistant.components.button import DOMAIN as DOMAIN_BUTTON from homeassistant.components.cover import DOMAIN as DOMAIN_COVER +from homeassistant.components.event import DOMAIN as DOMAIN_EVENT from homeassistant.components.fan import DOMAIN as DOMAIN_FAN from homeassistant.components.image import DOMAIN as DOMAIN_IMAGE from homeassistant.components.light import DOMAIN as DOMAIN_LIGHT @@ -53,6 +54,7 @@ binary_sensor as binary_sensor_platform, button as button_platform, cover as cover_platform, + event as event_platform, fan as fan_platform, image as image_platform, light as light_platform, @@ -124,6 +126,9 @@ def _backward_compat_schema(value: Any | None) -> Any: vol.Optional(DOMAIN_COVER): vol.All( cv.ensure_list, [cover_platform.COVER_YAML_SCHEMA] ), + vol.Optional(DOMAIN_EVENT): vol.All( + cv.ensure_list, [event_platform.EVENT_YAML_SCHEMA] + ), vol.Optional(DOMAIN_FAN): vol.All( cv.ensure_list, [fan_platform.FAN_YAML_SCHEMA] ), diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 2e581628da274..745e2933c58b5 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -12,6 +12,7 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.button import ButtonDeviceClass from homeassistant.components.cover import CoverDeviceClass +from homeassistant.components.event import EventDeviceClass from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASS_STATE_CLASSES, @@ -72,6 +73,7 @@ STOP_ACTION, async_create_preview_cover, ) +from .event import CONF_EVENT_TYPE, CONF_EVENT_TYPES, async_create_preview_event from .fan import ( CONF_OFF_ACTION, CONF_ON_ACTION, @@ -203,6 +205,24 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: ) } + if domain == Platform.EVENT: + schema |= { + vol.Required(CONF_EVENT_TYPE): selector.TemplateSelector(), + vol.Required(CONF_EVENT_TYPES): selector.TemplateSelector(), + } + + if flow_type == "config": + schema |= { + vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[cls.value for cls in EventDeviceClass], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="event_device_class", + sort=True, + ), + ) + } + if domain == Platform.FAN: schema |= _SCHEMA_STATE | { vol.Required(CONF_ON_ACTION): selector.ActionSelector(), @@ -441,6 +461,7 @@ async def _validate_user_input( Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, + Platform.EVENT, Platform.FAN, Platform.IMAGE, Platform.LIGHT, @@ -473,6 +494,11 @@ async def _validate_user_input( preview="template", validate_user_input=validate_user_input(Platform.COVER), ), + Platform.EVENT: SchemaFlowFormStep( + config_schema(Platform.EVENT), + preview="template", + validate_user_input=validate_user_input(Platform.EVENT), + ), Platform.FAN: SchemaFlowFormStep( config_schema(Platform.FAN), preview="template", @@ -542,6 +568,11 @@ async def _validate_user_input( preview="template", validate_user_input=validate_user_input(Platform.COVER), ), + Platform.EVENT: SchemaFlowFormStep( + options_schema(Platform.EVENT), + preview="template", + validate_user_input=validate_user_input(Platform.EVENT), + ), Platform.FAN: SchemaFlowFormStep( options_schema(Platform.FAN), preview="template", @@ -596,6 +627,7 @@ async def _validate_user_input( Platform.ALARM_CONTROL_PANEL: async_create_preview_alarm_control_panel, Platform.BINARY_SENSOR: async_create_preview_binary_sensor, Platform.COVER: async_create_preview_cover, + Platform.EVENT: async_create_preview_event, Platform.FAN: async_create_preview_fan, Platform.LIGHT: async_create_preview_light, Platform.LOCK: async_create_preview_lock, diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 2180567bf5990..43b5fcc255aff 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -38,6 +38,7 @@ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, + Platform.EVENT, Platform.FAN, Platform.IMAGE, Platform.LIGHT, diff --git a/homeassistant/components/template/event.py b/homeassistant/components/template/event.py new file mode 100644 index 0000000000000..358fec6a00fd2 --- /dev/null +++ b/homeassistant/components/template/event.py @@ -0,0 +1,235 @@ +"""Support for events which integrates with other components.""" + +from __future__ import annotations + +import logging +from typing import Any, Final + +import voluptuous as vol + +from homeassistant.components.event import ( + DOMAIN as EVENT_DOMAIN, + ENTITY_ID_FORMAT, + EventDeviceClass, + EventEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE_CLASS +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import TriggerUpdateCoordinator +from .entity import AbstractTemplateEntity +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TemplateEntity, + make_template_entity_common_modern_attributes_schema, +) +from .trigger_entity import TriggerEntity + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Template Event" + +CONF_EVENT_TYPE = "event_type" +CONF_EVENT_TYPES = "event_types" + +DEVICE_CLASS_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(EventDeviceClass)) + +EVENT_COMMON_SCHEMA = vol.Schema( + { + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASS_SCHEMA, + vol.Required(CONF_EVENT_TYPE): cv.template, + vol.Required(CONF_EVENT_TYPES): cv.template, + } +) + +EVENT_YAML_SCHEMA = EVENT_COMMON_SCHEMA.extend( + make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema +) + + +EVENT_CONFIG_ENTRY_SCHEMA = EVENT_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema +) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the template event.""" + await async_setup_template_platform( + hass, + EVENT_DOMAIN, + config, + StateEventEntity, + TriggerEventEntity, + async_add_entities, + discovery_info, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize config entry.""" + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateEventEntity, + EVENT_CONFIG_ENTRY_SCHEMA, + True, + ) + + +@callback +def async_create_preview_event( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> StateEventEntity: + """Create a preview event.""" + return async_setup_template_preview( + hass, + name, + config, + StateEventEntity, + EVENT_CONFIG_ENTRY_SCHEMA, + ) + + +class AbstractTemplateEvent(AbstractTemplateEntity, EventEntity): + """Representation of a template event features.""" + + _entity_id_format = ENTITY_ID_FORMAT + + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. + # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called + """Initialize the features.""" + self._event_type_template = config[CONF_EVENT_TYPE] + self._event_types_template = config[CONF_EVENT_TYPES] + + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + + self._event_type = None + self._attr_event_types = [] + + @callback + def _update_event_types(self, event_types: Any) -> None: + """Update the event types from the template.""" + if event_types in (None, "None", ""): + self._attr_event_types = [] + return + + if not isinstance(event_types, list): + _LOGGER.error( + ("Received invalid event_types list: %s for entity %s. Expected list"), + event_types, + self.entity_id, + ) + self._attr_event_types = [] + return + + self._attr_event_types = [str(event_type) for event_type in event_types] + + @callback + def _update_event_type(self, event_type: Any) -> None: + """Update the effect from the template.""" + try: + self._trigger_event(event_type) + except ValueError: + _LOGGER.error( + "Received invalid event_type: %s for entity %s. Expected one of: %s", + event_type, + self.entity_id, + self._attr_event_types, + ) + + +class StateEventEntity(TemplateEntity, AbstractTemplateEvent): + """Representation of a template event.""" + + _attr_should_poll = False + + def __init__( + self, + hass: HomeAssistant, + config: dict[str, Any], + unique_id: str | None, + ) -> None: + """Initialize the select.""" + TemplateEntity.__init__(self, hass, config, unique_id) + AbstractTemplateEvent.__init__(self, config) + + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" + self.add_template_attribute( + "_attr_event_types", + self._event_types_template, + None, + self._update_event_types, + none_on_template_error=True, + ) + self.add_template_attribute( + "_event_type", + self._event_type_template, + None, + self._update_event_type, + none_on_template_error=True, + ) + super()._async_setup_templates() + + +class TriggerEventEntity(TriggerEntity, AbstractTemplateEvent, RestoreEntity): + """Event entity based on trigger data.""" + + domain = EVENT_DOMAIN + extra_template_keys_complex = ( + CONF_EVENT_TYPE, + CONF_EVENT_TYPES, + ) + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize the entity.""" + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateEvent.__init__(self, config) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle update of the data.""" + self._process_data() + + if not self.available: + self.async_write_ha_state() + return + + for key, updater in ( + (CONF_EVENT_TYPES, self._update_event_types), + (CONF_EVENT_TYPE, self._update_event_type), + ): + updater(self._rendered[key]) + + self.async_set_context(self.coordinator.data["context"]) + self.async_write_ha_state() diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index dece4580098f7..6de26d885cbb4 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -136,6 +136,32 @@ }, "title": "Template cover" }, + "event": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "name": "[%key:common::config_flow::data::name%]", + "device_class": "[%key:component::template::common::device_class%]", + "event_type": "Last fired event type", + "event_types": "[%key:component::event::entity_component::_::state_attributes::event_types::name%]" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]", + "event_type": "Defines a template for the type of the event.", + "event_types": "Defines a template for a list of available event types." + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } + }, + "title": "Template event" + }, "fan": { "data": { "device_id": "[%key:common::config_flow::data::device%]", @@ -358,6 +384,7 @@ "binary_sensor": "Template a binary sensor", "button": "Template a button", "cover": "Template a cover", + "event": "Template an event", "fan": "Template a fan", "image": "Template an image", "light": "Template a light", @@ -565,6 +592,31 @@ }, "title": "[%key:component::template::config::step::cover::title%]" }, + "event": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "name": "[%key:common::config_flow::data::name%]", + "event_type": "[%key:component::template::config::step::event::data::event_type%]", + "event_types": "[%component::event::entity_component::_::state_attributes::event_types::name%]" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]", + "event_type": "[%key:component::template::config::step::event::data_description::event_type%]", + "event_types": "[%key:component::template::config::step::event::data_description::event_types%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" + } + } + }, + "title": "[%key:component::template::config::step::event::title%]" + }, "fan": { "data": { "device_id": "[%key:common::config_flow::data::device%]", @@ -905,6 +957,13 @@ "window": "[%key:component::cover::entity_component::window::name%]" } }, + "event_device_class": { + "options": { + "doorbell": "[%key:component::event::entity_component::doorbell::name%]", + "button": "[%key:component::event::entity_component::button::name%]", + "motion": "[%key:component::event::entity_component::motion::name%]" + } + }, "sensor_device_class": { "options": { "absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]", diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index d64a13619874f..2b4b71bcf18b6 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -14,7 +14,7 @@ "velbus-protocol" ], "quality_scale": "bronze", - "requirements": ["velbus-aio==2025.5.0"], + "requirements": ["velbus-aio==2025.8.0"], "usb": [ { "vid": "10CF", diff --git a/requirements_all.txt b/requirements_all.txt index a41c964a1fb55..0321ee7e8a765 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.68.4 +PySwitchbot==0.69.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -1307,7 +1307,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.8.21.181525 +knx-frontend==2025.8.24.205840 # homeassistant.components.konnected konnected==1.2.0 @@ -3043,7 +3043,7 @@ vegehub==0.1.24 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.5.0 +velbus-aio==2025.8.0 # homeassistant.components.venstar venstarcolortouch==0.21 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 921d6d9d1f51c..325e07457cee6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.68.4 +PySwitchbot==0.69.0 # homeassistant.components.syncthru PySyncThru==0.8.0 @@ -1129,7 +1129,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.8.21.181525 +knx-frontend==2025.8.24.205840 # homeassistant.components.konnected konnected==1.2.0 @@ -2511,7 +2511,7 @@ vegehub==0.1.24 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.5.0 +velbus-aio==2025.8.0 # homeassistant.components.venstar venstarcolortouch==0.21 diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index e7328603a59be..4f6b55fe3173f 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -57,53 +57,6 @@ async def test_async_setup_entry( assert all(len(p.entities) > 0 for p in platforms) -async def test_multiple_integrations(hass: HomeAssistant) -> None: - """Test successful setup for multiple entries.""" - # Load two integrations from two mock hosts. - status1 = MOCK_STATUS | {"LOADPCT": "15.0 Percent", "SERIALNO": "XXXXX1"} - status2 = MOCK_STATUS | {"LOADPCT": "16.0 Percent", "SERIALNO": "XXXXX2"} - entries = ( - await async_init_integration( - hass, host="test1", status=status1, entry_id="entry-id-1" - ), - await async_init_integration( - hass, host="test2", status=status2, entry_id="entry-id-2" - ), - ) - - assert len(hass.config_entries.async_entries(DOMAIN)) == 2 - assert all(entry.state is ConfigEntryState.LOADED for entry in entries) - - # Since the two UPS device names are the same, we will have to add a "_2" suffix. - device_slug = slugify(MOCK_STATUS["UPSNAME"]) - state1 = hass.states.get(f"sensor.{device_slug}_load") - state2 = hass.states.get(f"sensor.{device_slug}_load_2") - assert state1 is not None and state2 is not None - assert state1.state != state2.state - - -async def test_multiple_integrations_different_devices(hass: HomeAssistant) -> None: - """Test successful setup for multiple entries with different device names.""" - status1 = MOCK_STATUS | {"SERIALNO": "XXXXX1", "UPSNAME": "MyUPS1"} - status2 = MOCK_STATUS | {"SERIALNO": "XXXXX2", "UPSNAME": "MyUPS2"} - entries = ( - await async_init_integration( - hass, host="test1", status=status1, entry_id="entry-id-1" - ), - await async_init_integration( - hass, host="test2", status=status2, entry_id="entry-id-2" - ), - ) - - assert len(hass.config_entries.async_entries(DOMAIN)) == 2 - assert all(entry.state is ConfigEntryState.LOADED for entry in entries) - - # The device names are different, so they are prefixed differently. - state1 = hass.states.get("sensor.myups1_load") - state2 = hass.states.get("sensor.myups2_load") - assert state1 is not None and state2 is not None - - @pytest.mark.parametrize( "error", [OSError(), asyncio.IncompleteReadError(partial=b"", expected=0)], @@ -127,34 +80,18 @@ async def test_connection_error(hass: HomeAssistant, error: Exception) -> None: async def test_unload_remove_entry(hass: HomeAssistant) -> None: """Test successful unload and removal of an entry.""" - # Load two integrations from two mock hosts. - entries = ( - await async_init_integration( - hass, host="test1", status=MOCK_STATUS, entry_id="entry-id-1" - ), - await async_init_integration( - hass, host="test2", status=MOCK_MINIMAL_STATUS, entry_id="entry-id-2" - ), + entry = await async_init_integration( + hass, host="test1", status=MOCK_STATUS, entry_id="entry-id-1" ) + assert entry.state is ConfigEntryState.LOADED - # Assert they are loaded. - assert len(hass.config_entries.async_entries(DOMAIN)) == 2 - assert all(entry.state is ConfigEntryState.LOADED for entry in entries) - - # Unload the first entry. - assert await hass.config_entries.async_unload(entries[0].entry_id) - await hass.async_block_till_done() - assert entries[0].state is ConfigEntryState.NOT_LOADED - assert entries[1].state is ConfigEntryState.LOADED - - # Unload the second entry. - assert await hass.config_entries.async_unload(entries[1].entry_id) + # Unload the entry. + assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert all(entry.state is ConfigEntryState.NOT_LOADED for entry in entries) + assert entry.state is ConfigEntryState.NOT_LOADED - # Remove both entries. - for entry in entries: - await hass.config_entries.async_remove(entry.entry_id) + # Remove the entry. + await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() assert len(hass.config_entries.async_entries(DOMAIN)) == 0 diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py index 4da17b1c12885..af163d3cbc1f2 100644 --- a/tests/components/apcupsd/test_sensor.py +++ b/tests/components/apcupsd/test_sensor.py @@ -59,7 +59,10 @@ async def test_state_update(hass: HomeAssistant) -> None: async def test_manual_update_entity(hass: HomeAssistant) -> None: - """Test manual update entity via service homeassistant/update_entity.""" + """Test multiple simultaneous manual update entity via service homeassistant/update_entity. + + We should only do network call once for the multiple simultaneous update entity services. + """ await async_init_integration(hass) device_slug = slugify(MOCK_STATUS["UPSNAME"]) @@ -103,37 +106,6 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: assert state.state == "15.0" -async def test_multiple_manual_update_entity(hass: HomeAssistant) -> None: - """Test multiple simultaneous manual update entity via service homeassistant/update_entity. - - We should only do network call once for the multiple simultaneous update entity services. - """ - await async_init_integration(hass) - - device_slug = slugify(MOCK_STATUS["UPSNAME"]) - # Setup HASS for calling the update_entity service. - await async_setup_component(hass, "homeassistant", {}) - - with patch( - "aioapcaccess.request_status", return_value=MOCK_STATUS - ) as mock_request_status: - # Fast-forward time to just pass the initial debouncer cooldown. - future = utcnow() + timedelta(seconds=REQUEST_REFRESH_COOLDOWN) - async_fire_time_changed(hass, future) - await hass.services.async_call( - "homeassistant", - "update_entity", - { - ATTR_ENTITY_ID: [ - f"sensor.{device_slug}_load", - f"sensor.{device_slug}_input_voltage", - ] - }, - blocking=True, - ) - assert mock_request_status.call_count == 1 - - async def test_sensor_unknown(hass: HomeAssistant) -> None: """Test if our integration can properly mark certain sensors as unknown when it becomes so.""" await async_init_integration(hass, status=MOCK_MINIMAL_STATUS) diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index bf8d88ab479a7..3f8f9f0da6cd7 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -21,42 +21,47 @@ from tests.typing import WebSocketGenerator -async def test_knx_info_command( +async def test_knx_get_base_data_command( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator ) -> None: - """Test knx/info command.""" + """Test knx/get_base_data command.""" await knx.setup_integration() client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "knx/info"}) + await client.send_json_auto_id({"type": "knx/get_base_data"}) res = await client.receive_json() assert res["success"], res - assert res["result"]["version"] is not None - assert res["result"]["connected"] - assert res["result"]["current_address"] == "0.0.0" - assert res["result"]["project"] is None + assert res["result"]["connection_info"]["version"] is not None + assert res["result"]["connection_info"]["connected"] + assert res["result"]["connection_info"]["current_address"] == "0.0.0" + assert res["result"]["project_info"] is None + assert not SUPPORTED_PLATFORMS_UI.difference(res["result"]["supported_platforms"]) @pytest.mark.usefixtures("load_knxproj") -async def test_knx_info_command_with_project( +async def test_knx_get_base_data_command_with_project( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator, ) -> None: - """Test knx/info command with loaded project.""" + """Test knx/get_base_data command with loaded project.""" await knx.setup_integration() client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "knx/info"}) + await client.send_json_auto_id({"type": "knx/get_base_data"}) res = await client.receive_json() assert res["success"], res - assert res["result"]["version"] is not None - assert res["result"]["connected"] - assert res["result"]["current_address"] == "0.0.0" - assert res["result"]["project"] is not None - assert res["result"]["project"]["name"] == "Fixture" - assert res["result"]["project"]["last_modified"] == "2023-04-30T09:04:04.4043671Z" - assert res["result"]["project"]["tool_version"] == "5.7.1428.39779" + + connection_info = res["result"]["connection_info"] + assert connection_info["version"] is not None + assert connection_info["connected"] + assert connection_info["current_address"] == "0.0.0" + + project_info = res["result"]["project_info"] + assert project_info is not None + assert project_info["name"] == "Fixture" + assert project_info["last_modified"] == "2023-04-30T09:04:04.4043671Z" + assert project_info["tool_version"] == "5.7.1428.39779" async def test_knx_project_file_process( @@ -165,8 +170,24 @@ async def test_knx_get_project( await client.send_json_auto_id({"type": "knx/get_knx_project"}) res = await client.receive_json() assert res["success"], res - assert res["result"]["project_loaded"] is True - assert res["result"]["knxproject"] == project_data + assert res["result"] == project_data + + +async def test_knx_get_project_no_project( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + project_data: dict[str, Any], +) -> None: + """Test retrieval of kxnproject from store.""" + await knx.setup_integration() + client = await hass_ws_client(hass) + assert not hass.data[KNX_MODULE_KEY].project.loaded + + await client.send_json_auto_id({"type": "knx/get_knx_project"}) + res = await client.receive_json() + assert res["success"], res + assert res["result"] is None async def test_knx_group_monitor_info_command( @@ -414,7 +435,7 @@ async def test_knx_get_schema( @pytest.mark.parametrize( "endpoint", [ - "knx/info", # sync ws-command + "knx/get_base_data", # sync ws-command "knx/get_knx_project", # async ws-command ], ) diff --git a/tests/components/lg_thinq/conftest.py b/tests/components/lg_thinq/conftest.py index 2eaddf1a83b70..73abc8c507537 100644 --- a/tests/components/lg_thinq/conftest.py +++ b/tests/components/lg_thinq/conftest.py @@ -98,15 +98,6 @@ def mock_thinq_api(mock_thinq_mqtt_client: None) -> Generator[AsyncMock]: """Mock a thinq api.""" with patch("homeassistant.components.lg_thinq.ThinQApi", autospec=True) as mock_api: thinq_api = mock_api.return_value - thinq_api.async_get_device_list.return_value = [ - load_json_object_fixture("air_conditioner/device.json", DOMAIN) - ] - thinq_api.async_get_device_profile.return_value = load_json_object_fixture( - "air_conditioner/profile.json", DOMAIN - ) - thinq_api.async_get_device_status.return_value = load_json_object_fixture( - "air_conditioner/status.json", DOMAIN - ) yield thinq_api @@ -119,3 +110,31 @@ def mock_thinq_mqtt_client() -> Generator[None]: return_value=True, ): yield + + +@pytest.fixture( + params=[ + "air_conditioner", + "washer", + ] +) +def device_fixture( + mock_thinq_api: AsyncMock, request: pytest.FixtureRequest +) -> Generator[str]: + """Return every device.""" + return request.param + + +@pytest.fixture +def devices(mock_thinq_api: AsyncMock, device_fixture: str) -> Generator[AsyncMock]: + """Return a specific device.""" + mock_thinq_api.async_get_device_list.return_value = [ + load_json_object_fixture(f"{device_fixture}/device.json", DOMAIN) + ] + mock_thinq_api.async_get_device_profile.return_value = load_json_object_fixture( + f"{device_fixture}/profile.json", DOMAIN + ) + mock_thinq_api.async_get_device_status.return_value = load_json_object_fixture( + f"{device_fixture}/status.json", DOMAIN + ) + return mock_thinq_api diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/status.json b/tests/components/lg_thinq/fixtures/air_conditioner/status.json index 8440e7da28ce4..bffb13a1ac16f 100644 --- a/tests/components/lg_thinq/fixtures/air_conditioner/status.json +++ b/tests/components/lg_thinq/fixtures/air_conditioner/status.json @@ -44,7 +44,6 @@ "unit": "F" } ], - "timer": { "relativeStartTimer": "UNSET", "relativeStopTimer": "UNSET", diff --git a/tests/components/lg_thinq/fixtures/washer/device.json b/tests/components/lg_thinq/fixtures/washer/device.json new file mode 100644 index 0000000000000..33ea13669bd6c --- /dev/null +++ b/tests/components/lg_thinq/fixtures/washer/device.json @@ -0,0 +1,9 @@ +{ + "deviceId": "MW2-0B530EFD-1ADF-4F54-A2C3-46C37F94C689", + "deviceInfo": { + "deviceType": "DEVICE_WASHER", + "modelName": "FAFXU22027", + "alias": "Test washer", + "reportable": true + } +} diff --git a/tests/components/lg_thinq/fixtures/washer/profile.json b/tests/components/lg_thinq/fixtures/washer/profile.json new file mode 100644 index 0000000000000..6b78300b4f9b2 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/washer/profile.json @@ -0,0 +1,151 @@ +{ + "notification": { + "push": ["WASHING_IS_COMPLETE", "ERROR_DURING_WASHING"] + }, + "property": [ + { + "cycle": { + "cycleCount": { + "mode": ["r"], + "type": "number" + } + }, + "detergent": { + "detergentSetting": "NORMAL" + }, + "location": { + "locationName": "MAIN" + }, + "operation": { + "washerOperationMode": { + "mode": ["w"], + "type": "enum", + "value": { + "w": ["START", "STOP", "POWER_OFF", "POWER_ON"] + } + } + }, + "remoteControlEnable": { + "remoteControlEnabled": { + "mode": ["r"], + "type": "boolean", + "value": { + "r": [false, true] + } + } + }, + "runState": { + "currentState": { + "mode": ["r"], + "type": "enum", + "value": { + "r": [ + "DISPENSING", + "END", + "RUNNING", + "FROZEN_PREVENT_RUNNING", + "PAUSE", + "PREWASH", + "DETECTING", + "INITIAL", + "SOAKING", + "DRYING", + "FROZEN_PREVENT_PAUSE", + "FROZEN_PREVENT_INITIAL", + "REFRESHING", + "RINSING", + "STEAM_SOFTENING", + "DETERGENT_AMOUNT", + "POWER_OFF", + "RESERVED", + "RINSE_HOLD", + "ERROR", + "SPINNING", + "ADD_DRAIN" + ] + } + } + }, + "timer": { + "relativeHourToStart": { + "mode": ["r", "w"], + "type": "range", + "value": { + "r": { + "except": [], + "max": 19, + "min": 1, + "step": 1 + }, + "w": { + "except": [], + "max": 19, + "min": 1, + "step": 1 + } + } + }, + "relativeMinuteToStart": { + "mode": ["r"], + "type": "range", + "value": { + "r": { + "except": [], + "max": 30, + "min": 0, + "step": 1 + } + } + }, + "remainHour": { + "mode": ["r"], + "type": "range", + "value": { + "r": { + "except": [], + "max": 30, + "min": 0, + "step": 1 + } + } + }, + "remainMinute": { + "mode": ["r"], + "type": "range", + "value": { + "r": { + "except": [], + "max": 59, + "min": 0, + "step": 1 + } + } + }, + "totalHour": { + "mode": ["r"], + "type": "range", + "value": { + "r": { + "except": [], + "max": 30, + "min": 0, + "step": 1 + } + } + }, + "totalMinute": { + "mode": ["r"], + "type": "range", + "value": { + "r": { + "except": [], + "max": 59, + "min": 0, + "step": 1 + } + } + } + } + } + ] +} diff --git a/tests/components/lg_thinq/fixtures/washer/status.json b/tests/components/lg_thinq/fixtures/washer/status.json new file mode 100644 index 0000000000000..f325bc34691a2 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/washer/status.json @@ -0,0 +1,22 @@ +{ + "cycle": { + "cycleCount": 10 + }, + "location": { + "locationName": "MAIN" + }, + "remoteControlEnable": { + "remoteControlEnabled": false + }, + "runState": { + "currentState": "POWER_OFF" + }, + "timer": { + "relativeHourToStart": 0, + "relativeMinuteToStart": 0, + "remainHour": 0, + "remainMinute": 45, + "totalHour": 0, + "totalMinute": 50 + } +} diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr index 66d842050abdd..5c05244b3139c 100644 --- a/tests/components/lg_thinq/snapshots/test_climate.ambr +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[climate.test_air_conditioner-entry] +# name: test_climate_entities[air_conditioner][climate.test_air_conditioner-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -60,7 +60,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[climate.test_air_conditioner-state] +# name: test_climate_entities[air_conditioner][climate.test_air_conditioner-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_humidity': 40, diff --git a/tests/components/lg_thinq/snapshots/test_event.ambr b/tests/components/lg_thinq/snapshots/test_event.ambr index 670ce8985fa84..3f9e11849abe1 100644 --- a/tests/components/lg_thinq/snapshots/test_event.ambr +++ b/tests/components/lg_thinq/snapshots/test_event.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[event.test_air_conditioner_notification-entry] +# name: test_event_entities[air_conditioner][event.test_air_conditioner_notification-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -38,7 +38,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[event.test_air_conditioner_notification-state] +# name: test_event_entities[air_conditioner][event.test_air_conditioner_notification-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'event_type': None, diff --git a/tests/components/lg_thinq/snapshots/test_number.ambr b/tests/components/lg_thinq/snapshots/test_number.ambr index 5fa03b6003381..28403ab73370c 100644 --- a/tests/components/lg_thinq/snapshots/test_number.ambr +++ b/tests/components/lg_thinq/snapshots/test_number.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[number.test_air_conditioner_schedule_turn_off-entry] +# name: test_number_entities[air_conditioner][number.test_air_conditioner_schedule_turn_off-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -39,7 +39,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[number.test_air_conditioner_schedule_turn_off-state] +# name: test_number_entities[air_conditioner][number.test_air_conditioner_schedule_turn_off-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test air conditioner Schedule turn-off', @@ -57,7 +57,7 @@ 'state': 'unknown', }) # --- -# name: test_all_entities[number.test_air_conditioner_schedule_turn_on-entry] +# name: test_number_entities[air_conditioner][number.test_air_conditioner_schedule_turn_on-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -97,7 +97,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[number.test_air_conditioner_schedule_turn_on-state] +# name: test_number_entities[air_conditioner][number.test_air_conditioner_schedule_turn_on-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test air conditioner Schedule turn-on', @@ -115,3 +115,61 @@ 'state': 'unknown', }) # --- +# name: test_number_entities[washer][number.test_washer_delayed_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 19, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_washer_delayed_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Delayed start', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-0B530EFD-1ADF-4F54-A2C3-46C37F94C689_main_relative_hour_to_start', + 'unit_of_measurement': , + }) +# --- +# name: test_number_entities[washer][number.test_washer_delayed_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test washer Delayed start', + 'max': 19, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_washer_delayed_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr index 3f42d7e4f5ca0..1ab4ede5a5b45 100644 --- a/tests/components/lg_thinq/snapshots/test_sensor.ambr +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[sensor.test_air_conditioner_filter_remaining-entry] +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_filter_remaining-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -34,7 +34,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.test_air_conditioner_filter_remaining-state] +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_filter_remaining-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test air conditioner Filter remaining', @@ -48,7 +48,7 @@ 'state': '540', }) # --- -# name: test_all_entities[sensor.test_air_conditioner_humidity-entry] +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -85,7 +85,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_all_entities[sensor.test_air_conditioner_humidity-state] +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_humidity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', @@ -101,7 +101,7 @@ 'state': '40', }) # --- -# name: test_all_entities[sensor.test_air_conditioner_pm1-entry] +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_pm1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -138,7 +138,7 @@ 'unit_of_measurement': 'μg/m³', }) # --- -# name: test_all_entities[sensor.test_air_conditioner_pm1-state] +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_pm1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'pm1', @@ -154,7 +154,7 @@ 'state': '12', }) # --- -# name: test_all_entities[sensor.test_air_conditioner_pm10-entry] +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_pm10-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -191,7 +191,7 @@ 'unit_of_measurement': 'μg/m³', }) # --- -# name: test_all_entities[sensor.test_air_conditioner_pm10-state] +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_pm10-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'pm10', @@ -207,7 +207,7 @@ 'state': '7', }) # --- -# name: test_all_entities[sensor.test_air_conditioner_pm2_5-entry] +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_pm2_5-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -244,7 +244,7 @@ 'unit_of_measurement': 'μg/m³', }) # --- -# name: test_all_entities[sensor.test_air_conditioner_pm2_5-state] +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_pm2_5-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'pm25', @@ -260,7 +260,7 @@ 'state': '24', }) # --- -# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-entry] +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_schedule_turn_off-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -298,7 +298,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-state] +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_schedule_turn_off-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -313,7 +313,7 @@ 'state': 'unknown', }) # --- -# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-entry] +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_schedule_turn_on-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -351,7 +351,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-state] +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_schedule_turn_on-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -366,7 +366,7 @@ 'state': 'unknown', }) # --- -# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on_2-entry] +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_schedule_turn_on_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -401,7 +401,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on_2-state] +# name: test_sensor_entities[air_conditioner][sensor.test_air_conditioner_schedule_turn_on_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', diff --git a/tests/components/lg_thinq/snapshots/test_switch.ambr b/tests/components/lg_thinq/snapshots/test_switch.ambr new file mode 100644 index 0000000000000..e427916630b87 --- /dev/null +++ b/tests/components/lg_thinq/snapshots/test_switch.ambr @@ -0,0 +1,197 @@ +# serializer version: 1 +# name: test_switch_entities[washer][switch.test_washer_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_washer_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'operation_power', + 'unique_id': 'MW2-0B530EFD-1ADF-4F54-A2C3-46C37F94C689_main_washer_operation_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[washer][switch.test_washer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test washer Power', + }), + 'context': , + 'entity_id': 'switch.test_washer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_entities[air_conditioner][switch.test_air_conditioner_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_air_conditioner_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'operation_power', + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_air_con_operation_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[air_conditioner][switch.test_air_conditioner_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test air conditioner Power', + }), + 'context': , + 'entity_id': 'switch.test_air_conditioner_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities[air_conditioner][switch.test_air_conditioner_energy_saving-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_air_conditioner_energy_saving', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saving', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_power_save_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[air_conditioner][switch.test_air_conditioner_energy_saving-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test air conditioner Energy saving', + }), + 'context': , + 'entity_id': 'switch.test_air_conditioner_energy_saving', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_entities[air_conditioner][switch.test_air_conditioner_air_purify-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_air_conditioner_air_purify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air purify', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_air_clean_operation_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[air_conditioner][switch.test_air_conditioner_air_purify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test air conditioner Air purify', + }), + 'context': , + 'entity_id': 'switch.test_air_conditioner_air_purify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- \ No newline at end of file diff --git a/tests/components/lg_thinq/test_climate.py b/tests/components/lg_thinq/test_climate.py index c79331dd63802..6b80151805c2c 100644 --- a/tests/components/lg_thinq/test_climate.py +++ b/tests/components/lg_thinq/test_climate.py @@ -16,9 +16,11 @@ @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_all_entities( +@pytest.mark.parametrize("device_fixture", ["air_conditioner"]) +async def test_climate_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, + devices: AsyncMock, mock_thinq_api: AsyncMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, diff --git a/tests/components/lg_thinq/test_config_flow.py b/tests/components/lg_thinq/test_config_flow.py index a46162723f0c7..2612519824d1f 100644 --- a/tests/components/lg_thinq/test_config_flow.py +++ b/tests/components/lg_thinq/test_config_flow.py @@ -49,8 +49,7 @@ async def test_config_flow( async def test_config_flow_invalid_pat( - hass: HomeAssistant, - mock_invalid_thinq_api: AsyncMock, + hass: HomeAssistant, mock_invalid_thinq_api: AsyncMock ) -> None: """Test that an thinq flow should be aborted with an invalid PAT.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/lg_thinq/test_event.py b/tests/components/lg_thinq/test_event.py index 398af1e8aad78..c6aa176c4291a 100644 --- a/tests/components/lg_thinq/test_event.py +++ b/tests/components/lg_thinq/test_event.py @@ -15,9 +15,11 @@ @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_all_entities( +@pytest.mark.parametrize("device_fixture", ["air_conditioner"]) +async def test_event_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, + devices: AsyncMock, mock_thinq_api: AsyncMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, diff --git a/tests/components/lg_thinq/test_number.py b/tests/components/lg_thinq/test_number.py index 7c37ba3f5e0aa..b36685a8aa400 100644 --- a/tests/components/lg_thinq/test_number.py +++ b/tests/components/lg_thinq/test_number.py @@ -15,9 +15,10 @@ @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_all_entities( +async def test_number_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, + devices: AsyncMock, mock_thinq_api: AsyncMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, diff --git a/tests/components/lg_thinq/test_sensor.py b/tests/components/lg_thinq/test_sensor.py index e2c8e122eeaf0..87f03de6c0de7 100644 --- a/tests/components/lg_thinq/test_sensor.py +++ b/tests/components/lg_thinq/test_sensor.py @@ -15,11 +15,13 @@ from tests.common import MockConfigEntry, snapshot_platform +@pytest.mark.parametrize("device_fixture", ["air_conditioner"]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.freeze_time(datetime(2024, 10, 10, tzinfo=UTC)) -async def test_all_entities( +async def test_sensor_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, + devices: AsyncMock, mock_thinq_api: AsyncMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, diff --git a/tests/components/lg_thinq/test_switch.py b/tests/components/lg_thinq/test_switch.py new file mode 100644 index 0000000000000..0e2039f49d002 --- /dev/null +++ b/tests/components/lg_thinq/test_switch.py @@ -0,0 +1,28 @@ +"""Tests for the LG ThinQ switch platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_switch_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/schedule/test_init.py b/tests/components/schedule/test_init.py index 6fd6314c6bb2a..9a9ccc0c47a53 100644 --- a/tests/components/schedule/test_init.py +++ b/tests/components/schedule/test_init.py @@ -617,11 +617,26 @@ async def test_ws_delete( @pytest.mark.freeze_time("2022-08-10 20:10:00-07:00") @pytest.mark.parametrize( - ("to", "next_event", "saved_to"), + ("to", "next_event", "saved_to", "icon_dict"), [ - ("23:59:59", "2022-08-10T23:59:59-07:00", "23:59:59"), - ("24:00", "2022-08-11T00:00:00-07:00", "24:00:00"), - ("24:00:00", "2022-08-11T00:00:00-07:00", "24:00:00"), + ( + "23:59:59", + "2022-08-10T23:59:59-07:00", + "23:59:59", + {CONF_ICON: "mdi:party-pooper"}, + ), + ( + "24:00", + "2022-08-11T00:00:00-07:00", + "24:00:00", + {CONF_ICON: "mdi:party-popper"}, + ), + ( + "24:00:00", + "2022-08-11T00:00:00-07:00", + "24:00:00", + {}, + ), ], ) async def test_update( @@ -632,6 +647,7 @@ async def test_update( to: str, next_event: str, saved_to: str, + icon_dict: dict, ) -> None: """Test updating the schedule.""" assert await schedule_setup() @@ -654,7 +670,7 @@ async def test_update( "type": f"{DOMAIN}/update", f"{DOMAIN}_id": "from_storage", CONF_NAME: "Party pooper", - CONF_ICON: "mdi:party-pooper", + **icon_dict, CONF_MONDAY: [], CONF_TUESDAY: [], CONF_WEDNESDAY: [{CONF_FROM: "17:00:00", CONF_TO: to}], @@ -671,7 +687,7 @@ async def test_update( assert state assert state.state == STATE_ON assert state.attributes[ATTR_FRIENDLY_NAME] == "Party pooper" - assert state.attributes[ATTR_ICON] == "mdi:party-pooper" + assert state.attributes.get(ATTR_ICON) == icon_dict.get(CONF_ICON) assert state.attributes[ATTR_NEXT_EVENT].isoformat() == next_event await client.send_json({"id": 2, "type": f"{DOMAIN}/list"}) diff --git a/tests/components/template/snapshots/test_event.ambr b/tests/components/template/snapshots/test_event.ambr new file mode 100644 index 0000000000000..98ec3ffa8c035 --- /dev/null +++ b/tests/components/template/snapshots/test_event.ambr @@ -0,0 +1,20 @@ +# serializer version: 1 +# name: test_setup_config_entry + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': 'single', + 'event_types': list([ + 'single', + 'double', + 'hold', + ]), + 'friendly_name': 'template_event', + }), + 'context': , + 'entity_id': 'event.template_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-07-09T00:00:00.000+00:00', + }) +# --- diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 081040255827d..49a9d5a1e5fbb 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -149,6 +149,16 @@ }, {}, ), + ( + "event", + {"event_type": "{{ states('event.one') }}"}, + "2024-07-09T00:00:00.000+00:00", + {"one": "single", "two": "double"}, + {}, + {"event_types": "{{ ['single', 'double'] }}"}, + {"event_types": "{{ ['single', 'double'] }}"}, + {}, + ), ( "fan", {"state": "{{ states('fan.one') }}"}, @@ -362,6 +372,12 @@ async def test_config_flow( {"set_cover_position": []}, {"set_cover_position": []}, ), + ( + "event", + {"event_type": "{{ 'single' }}"}, + {"event_types": "{{ ['single', 'double'] }}"}, + {"event_types": "{{ ['single', 'double'] }}"}, + ), ( "fan", {"state": "{{ states('fan.one') }}"}, @@ -582,6 +598,16 @@ async def test_config_flow_device( {"set_cover_position": []}, "state", ), + ( + "event", + {"event_type": "{{ states('event.one') }}"}, + {"event_type": "{{ states('event.two') }}"}, + ["2024-07-09T00:00:00.000+00:00", "2024-07-09T00:00:00.000+00:00"], + {"one": "single", "two": "double"}, + {"event_types": "{{ ['single', 'double'] }}"}, + {"event_types": "{{ ['single', 'double'] }}"}, + "event_type", + ), ( "fan", {"state": "{{ states('fan.one') }}"}, @@ -1469,6 +1495,12 @@ async def test_option_flow_sensor_preview_config_entry_removed( {"set_cover_position": []}, {"set_cover_position": []}, ), + ( + "event", + {"event_type": "{{ 'single' }}"}, + {"event_types": "{{ ['single', 'double'] }}"}, + {"event_types": "{{ ['single', 'double'] }}"}, + ), ( "fan", {"state": "{{ states('fan.one') }}"}, diff --git a/tests/components/template/test_event.py b/tests/components/template/test_event.py new file mode 100644 index 0000000000000..4efa488a66a6d --- /dev/null +++ b/tests/components/template/test_event.py @@ -0,0 +1,823 @@ +"""The tests for the Template event platform.""" + +from typing import Any + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import event, template +from homeassistant.const import ( + ATTR_ENTITY_PICTURE, + ATTR_ICON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import ConfigurationStyle, async_get_flow_preview_state + +from tests.common import ( + MockConfigEntry, + assert_setup_component, + mock_restore_cache_with_extra_data, +) +from tests.conftest import WebSocketGenerator + +TEST_OBJECT_ID = "template_event" +TEST_ENTITY_ID = f"event.{TEST_OBJECT_ID}" +TEST_SENSOR = "sensor.event" +TEST_STATE_TRIGGER = { + "trigger": {"trigger": "state", "entity_id": TEST_SENSOR}, + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} + ], +} +TEST_EVENT_TYPES_TEMPLATE = "{{ ['single', 'double', 'hold'] }}" +TEST_EVENT_TYPE_TEMPLATE = "{{ 'single' }}" + +TEST_EVENT_CONFIG = { + "event_types": TEST_EVENT_TYPES_TEMPLATE, + "event_type": TEST_EVENT_TYPE_TEMPLATE, +} +TEST_UNIQUE_ID_CONFIG = { + **TEST_EVENT_CONFIG, + "unique_id": "not-so-unique-anymore", +} +TEST_FROZEN_INPUT = "2024-07-09 00:00:00+00:00" +TEST_FROZEN_STATE = "2024-07-09T00:00:00.000+00:00" + + +async def async_setup_modern_format( + hass: HomeAssistant, + count: int, + event_config: dict[str, Any], + extra_config: dict[str, Any] | None, +) -> None: + """Do setup of event integration via new format.""" + extra = extra_config if extra_config else {} + config = {**event_config, **extra} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + {"template": {"event": config}}, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_trigger_format( + hass: HomeAssistant, + count: int, + event_config: dict[str, Any], + extra_config: dict[str, Any] | None, +) -> None: + """Do setup of event integration via trigger format.""" + extra = extra_config if extra_config else {} + config = { + "template": { + **TEST_STATE_TRIGGER, + "event": {**event_config, **extra}, + } + } + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_event_config( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + event_config: dict[str, Any], + extra_config: dict[str, Any] | None, +) -> None: + """Do setup of event integration.""" + if style == ConfigurationStyle.MODERN: + await async_setup_modern_format(hass, count, event_config, extra_config) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format(hass, count, event_config, extra_config) + + +@pytest.fixture +async def setup_base_event( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + event_config: dict[str, Any], +) -> None: + """Do setup of event integration.""" + await async_setup_event_config( + hass, + count, + style, + event_config, + None, + ) + + +@pytest.fixture +async def setup_event( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + event_type_template: str, + event_types_template: str, + extra_config: dict[str, Any] | None, +) -> None: + """Do setup of event integration.""" + await async_setup_event_config( + hass, + count, + style, + { + "name": TEST_OBJECT_ID, + "event_type": event_type_template, + "event_types": event_types_template, + }, + extra_config, + ) + + +@pytest.fixture +async def setup_single_attribute_state_event( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + event_type_template: str, + event_types_template: str, + attribute: str, + attribute_template: str, +) -> None: + """Do setup of event integration testing a single attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + config = { + "name": TEST_OBJECT_ID, + "event_type": event_type_template, + "event_types": event_types_template, + } + if style == ConfigurationStyle.MODERN: + await async_setup_modern_format(hass, count, config, extra) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format(hass, count, config, extra) + + +async def test_legacy_platform_config(hass: HomeAssistant) -> None: + """Test a legacy platform does not create event entities.""" + with assert_setup_component(1, event.DOMAIN): + assert await async_setup_component( + hass, + event.DOMAIN, + {"event": {"platform": "template", "events": {TEST_OBJECT_ID: {}}}}, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + assert hass.states.async_all("event") == [] + + +@pytest.mark.freeze_time(TEST_FROZEN_INPUT) +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test the config flow.""" + + hass.states.async_set( + TEST_SENSOR, + "single", + {}, + ) + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": TEST_OBJECT_ID, + "event_type": TEST_EVENT_TYPE_TEMPLATE, + "event_types": TEST_EVENT_TYPES_TEMPLATE, + "template_type": event.DOMAIN, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state == snapshot + + +@pytest.mark.freeze_time(TEST_FROZEN_INPUT) +async def test_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test for device for Template.""" + + device_config_entry = MockConfigEntry() + device_config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=device_config_entry.entry_id, + identifiers={("test", "identifier_test")}, + connections={("mac", "30:31:32:33:34:35")}, + ) + await hass.async_block_till_done() + assert device_entry is not None + assert device_entry.id is not None + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "event_type": TEST_EVENT_TYPE_TEMPLATE, + "event_types": TEST_EVENT_TYPES_TEMPLATE, + "template_type": "event", + "device_id": device_entry.id, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + template_entity = entity_registry.async_get("event.my_template") + assert template_entity is not None + assert template_entity.device_id == device_entry.id + + +@pytest.mark.parametrize( + ("count", "event_types_template", "extra_config"), + [(1, TEST_EVENT_TYPES_TEMPLATE, None)], +) +@pytest.mark.parametrize( + ("style", "expected_state"), + [ + (ConfigurationStyle.MODERN, STATE_UNKNOWN), + (ConfigurationStyle.TRIGGER, STATE_UNKNOWN), + ], +) +@pytest.mark.parametrize("event_type_template", ["{{states.test['big.fat...']}}"]) +@pytest.mark.usefixtures("setup_event") +async def test_event_type_syntax_error( + hass: HomeAssistant, + expected_state: str, +) -> None: + """Test template event_type with render error.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("count", "event_type_template", "event_types_template", "extra_config"), + [(1, "{{ states('sensor.event') }}", TEST_EVENT_TYPES_TEMPLATE, None)], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("event", "expected"), + [ + ("single", "single"), + ("double", "double"), + ("hold", "hold"), + ], +) +@pytest.mark.usefixtures("setup_event") +async def test_event_type_template( + hass: HomeAssistant, + event: str, + expected: str, +) -> None: + """Test template event_type.""" + hass.states.async_set(TEST_SENSOR, event) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["event_type"] == expected + + +@pytest.mark.parametrize( + ("count", "event_type_template", "event_types_template", "extra_config"), + [(1, "{{ states('sensor.event') }}", TEST_EVENT_TYPES_TEMPLATE, None)], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_event") +@pytest.mark.freeze_time(TEST_FROZEN_INPUT) +async def test_event_type_template_updates( + hass: HomeAssistant, +) -> None: + """Test template event_type updates.""" + hass.states.async_set(TEST_SENSOR, "single") + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == TEST_FROZEN_STATE + assert state.attributes["event_type"] == "single" + + hass.states.async_set(TEST_SENSOR, "double") + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == TEST_FROZEN_STATE + assert state.attributes["event_type"] == "double" + + hass.states.async_set(TEST_SENSOR, "hold") + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == TEST_FROZEN_STATE + assert state.attributes["event_type"] == "hold" + + +@pytest.mark.parametrize( + ("count", "event_types_template", "extra_config"), + [(1, TEST_EVENT_TYPES_TEMPLATE, None)], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + "event_type_template", + [ + "{{ None }}", + "{{ 7 }}", + "{{ 'unknown' }}", + "{{ 'tripple_double' }}", + ], +) +@pytest.mark.usefixtures("setup_event") +async def test_event_type_invalid( + hass: HomeAssistant, +) -> None: + """Test template event_type.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + assert state.attributes["event_type"] is None + + +@pytest.mark.parametrize( + ("count", "event_type_template", "event_types_template"), + [(1, "{{ states('sensor.event') }}", TEST_EVENT_TYPES_TEMPLATE)], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("attribute", "attribute_template", "key", "expected"), + [ + ( + "picture", + "{% if is_state('sensor.event', 'double') %}something{% endif %}", + ATTR_ENTITY_PICTURE, + "something", + ), + ( + "icon", + "{% if is_state('sensor.event', 'double') %}mdi:something{% endif %}", + ATTR_ICON, + "mdi:something", + ), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_event") +async def test_entity_picture_and_icon_templates( + hass: HomeAssistant, key: str, expected: str +) -> None: + """Test picture and icon template.""" + state = hass.states.async_set(TEST_SENSOR, "single") + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get(key) in ("", None) + + state = hass.states.async_set(TEST_SENSOR, "double") + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + + assert state.attributes[key] == expected + + +@pytest.mark.parametrize( + ("count", "event_type_template", "extra_config"), + [(1, "{{ None }}", None)], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("event_types_template", "expected"), + [ + ( + "{{ ['Strobe color', 'Police', 'Christmas', 'RGB', 'Random Loop'] }}", + ["Strobe color", "Police", "Christmas", "RGB", "Random Loop"], + ), + ( + "{{ ['Police', 'RGB', 'Random Loop'] }}", + ["Police", "RGB", "Random Loop"], + ), + ("{{ [] }}", []), + ("{{ '[]' }}", []), + ("{{ 124 }}", []), + ("{{ '124' }}", []), + ("{{ none }}", []), + ("", []), + ], +) +@pytest.mark.usefixtures("setup_event") +async def test_event_types_template(hass: HomeAssistant, expected: str) -> None: + """Test template event_types.""" + hass.states.async_set(TEST_SENSOR, "anything") + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["event_types"] == expected + + +@pytest.mark.parametrize( + ("count", "event_type_template", "event_types_template", "extra_config"), + [ + ( + 1, + "{{ states('sensor.event') }}", + "{{ state_attr('sensor.event', 'options') or ['unknown'] }}", + None, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_event") +@pytest.mark.freeze_time(TEST_FROZEN_INPUT) +async def test_event_types_template_updates(hass: HomeAssistant) -> None: + """Test template event_type update with entity.""" + hass.states.async_set( + TEST_SENSOR, "single", {"options": ["single", "double", "hold"]} + ) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == TEST_FROZEN_STATE + assert state.attributes["event_type"] == "single" + assert state.attributes["event_types"] == ["single", "double", "hold"] + + hass.states.async_set(TEST_SENSOR, "double", {"options": ["double", "hold"]}) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == TEST_FROZEN_STATE + assert state.attributes["event_type"] == "double" + assert state.attributes["event_types"] == ["double", "hold"] + + +@pytest.mark.parametrize( + ( + "count", + "event_type_template", + "event_types_template", + "attribute", + "attribute_template", + ), + [ + ( + 1, + "{{ states('sensor.event') }}", + TEST_EVENT_TYPES_TEMPLATE, + "availability", + "{{ states('sensor.event') in ['single', 'double', 'hold'] }}", + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_single_attribute_state_event") +async def test_available_template_with_entities(hass: HomeAssistant) -> None: + """Test availability templates with values from other entities.""" + hass.states.async_set(TEST_SENSOR, "single") + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state != STATE_UNAVAILABLE + assert state.attributes["event_type"] == "single" + + hass.states.async_set(TEST_SENSOR, "triple") + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNAVAILABLE + assert "event_type" not in state.attributes + + +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "event": { + "name": TEST_OBJECT_ID, + "event_type": "{{ trigger.event.data.action }}", + "event_types": TEST_EVENT_TYPES_TEMPLATE, + "picture": "{{ '/local/dogs.png' }}", + "icon": "{{ 'mdi:pirate' }}", + "attributes": { + "plus_one": "{{ trigger.event.data.beer + 1 }}", + "plus_two": "{{ trigger.event.data.beer + 2 }}", + }, + }, + }, + }, + ], +) +async def test_trigger_entity_restore_state( + hass: HomeAssistant, + count: int, + domain: str, + config: dict, +) -> None: + """Test restoring trigger event entities.""" + restored_attributes = { + "entity_picture": "/local/cats.png", + "event_type": "hold", + "icon": "mdi:ship", + "plus_one": 55, + } + fake_state = State( + TEST_ENTITY_ID, + "2021-01-01T23:59:59.123+00:00", + restored_attributes, + ) + fake_extra_data = { + "last_event_type": "hold", + "last_event_attributes": restored_attributes, + } + mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) + with assert_setup_component(count, domain): + assert await async_setup_component( + hass, + domain, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + test_state = "2021-01-01T23:59:59.123+00:00" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == test_state + for attr, value in restored_attributes.items(): + assert state.attributes[attr] == value + assert "plus_two" not in state.attributes + + hass.bus.async_fire("test_event", {"action": "double", "beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state != test_state + assert state.attributes["icon"] == "mdi:pirate" + assert state.attributes["entity_picture"] == "/local/dogs.png" + assert state.attributes["event_type"] == "double" + assert state.attributes["event_types"] == ["single", "double", "hold"] + assert state.attributes["plus_one"] == 3 + assert state.attributes["plus_two"] == 4 + + +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": { + "event": { + "name": TEST_OBJECT_ID, + "event_type": "{{ states('sensor.event') }}", + "event_types": TEST_EVENT_TYPES_TEMPLATE, + }, + }, + }, + ], +) +async def test_event_entity_restore_state( + hass: HomeAssistant, + count: int, + domain: str, + config: dict, +) -> None: + """Test restoring trigger event entities.""" + fake_state = State( + TEST_ENTITY_ID, + "2021-01-01T23:59:59.123+00:00", + {}, + ) + fake_extra_data = { + "last_event_type": "hold", + "last_event_attributes": {}, + } + mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) + with assert_setup_component(count, domain): + assert await async_setup_component( + hass, + domain, + config, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + test_state = "2021-01-01T23:59:59.123+00:00" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == test_state + + hass.states.async_set(TEST_SENSOR, "double") + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state != test_state + assert state.attributes["event_type"] == "double" + + +@pytest.mark.parametrize( + ( + "count", + "event_type_template", + "event_types_template", + "attribute", + "attribute_template", + ), + [ + ( + 1, + TEST_EVENT_TYPE_TEMPLATE, + TEST_EVENT_TYPES_TEMPLATE, + "availability", + "{{ x - 12 }}", + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_single_attribute_state_event") +async def test_invalid_availability_template_keeps_component_available( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + caplog_setup_text, +) -> None: + """Test that an invalid availability keeps the device available.""" + hass.states.async_set(TEST_SENSOR, "anything") + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + + error = "UndefinedError: 'x' is undefined" + assert error in caplog_setup_text or error in caplog.text + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("events", "style"), + [ + ( + [ + { + "name": "test_template_event_01", + **TEST_UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_event_02", + **TEST_UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.MODERN, + ), + ( + [ + { + "name": "test_template_event_01", + **TEST_UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_event_02", + **TEST_UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.TRIGGER, + ), + ], +) +async def test_unique_id( + hass: HomeAssistant, count: int, events: list[dict], style: ConfigurationStyle +) -> None: + """Test unique_id option only creates one event per id.""" + config = {"event": events} + if style == ConfigurationStyle.TRIGGER: + config = {**config, **TEST_STATE_TRIGGER} + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + {"template": config}, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all("event")) == 1 + + +async def test_nested_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test unique_id option creates one event per nested id.""" + + with assert_setup_component(1, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + { + "template": { + "unique_id": "x", + "event": [ + { + "name": "test_a", + **TEST_EVENT_CONFIG, + "unique_id": "a", + }, + { + "name": "test_b", + **TEST_EVENT_CONFIG, + "unique_id": "b", + }, + ], + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all("event")) == 2 + + entry = entity_registry.async_get("event.test_a") + assert entry + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get("event.test_b") + assert entry + assert entry.unique_id == "x-b" + + +@pytest.mark.freeze_time(TEST_FROZEN_INPUT) +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + event.DOMAIN, + {"name": "My template", **TEST_EVENT_CONFIG}, + ) + + assert state["state"] == TEST_FROZEN_STATE + assert state["attributes"]["event_type"] == "single" + assert state["attributes"]["event_types"] == ["single", "double", "hold"] diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index 0d593da9fbac5..8efca13a218bc 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -364,6 +364,18 @@ async def async_yaml_patch_helper(hass: HomeAssistant, filename: str) -> None: "value_template": "{{ true }}", }, ), + ( + { + "template_type": "event", + "name": "My template", + "event_type": "{{ 'single' }}", + "event_types": "{{ ['single', 'double'] }}", + }, + { + "event_type": "{{ 'single' }}", + "event_types": "{{ ['single', 'double'] }}", + }, + ), ], ) async def test_change_device(