From 58d4fd0b755adf8f965dde680b741d7b0674f8c8 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 25 Aug 2025 08:43:23 -0400 Subject: [PATCH] Add update platform to template integration (#150277) --- homeassistant/components/template/config.py | 5 + .../components/template/config_flow.py | 51 + homeassistant/components/template/const.py | 1 + .../components/template/strings.json | 90 ++ homeassistant/components/template/update.py | 463 +++++++ .../template/snapshots/test_update.ambr | 26 + tests/components/template/test_config_flow.py | 32 + tests/components/template/test_init.py | 12 + tests/components/template/test_update.py | 1085 +++++++++++++++++ 9 files changed, 1765 insertions(+) create mode 100644 homeassistant/components/template/update.py create mode 100644 tests/components/template/snapshots/test_update.ambr create mode 100644 tests/components/template/test_update.py diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 092dbc9e41e..ad2402bb980 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -26,6 +26,7 @@ from homeassistant.components.number import DOMAIN as DOMAIN_NUMBER from homeassistant.components.select import DOMAIN as DOMAIN_SELECT from homeassistant.components.sensor import DOMAIN as DOMAIN_SENSOR from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH +from homeassistant.components.update import DOMAIN as DOMAIN_UPDATE from homeassistant.components.vacuum import DOMAIN as DOMAIN_VACUUM from homeassistant.components.weather import DOMAIN as DOMAIN_WEATHER from homeassistant.config import async_log_schema_error, config_without_domain @@ -63,6 +64,7 @@ from . import ( select as select_platform, sensor as sensor_platform, switch as switch_platform, + update as update_platform, vacuum as vacuum_platform, weather as weather_platform, ) @@ -153,6 +155,9 @@ CONFIG_SECTION_SCHEMA = vol.All( vol.Optional(DOMAIN_SWITCH): vol.All( cv.ensure_list, [switch_platform.SWITCH_YAML_SCHEMA] ), + vol.Optional(DOMAIN_UPDATE): vol.All( + cv.ensure_list, [update_platform.UPDATE_YAML_SCHEMA] + ), vol.Optional(DOMAIN_VACUUM): vol.All( cv.ensure_list, [vacuum_platform.VACUUM_YAML_SCHEMA] ), diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 745e2933c58..36c27aa19f9 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -20,6 +20,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorStateClass, ) +from homeassistant.components.update import UpdateDeviceClass from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_DEVICE_ID, @@ -106,6 +107,19 @@ from .select import CONF_OPTIONS, CONF_SELECT_OPTION, async_create_preview_selec from .sensor import async_create_preview_sensor from .switch import async_create_preview_switch from .template_entity import TemplateEntity +from .update import ( + CONF_BACKUP, + CONF_IN_PROGRESS, + CONF_INSTALL, + CONF_INSTALLED_VERSION, + CONF_LATEST_VERSION, + CONF_RELEASE_SUMMARY, + CONF_RELEASE_URL, + CONF_SPECIFIC_VERSION, + CONF_TITLE, + CONF_UPDATE_PERCENTAGE, + async_create_preview_update, +) from .vacuum import ( CONF_FAN_SPEED, CONF_FAN_SPEED_LIST, @@ -335,6 +349,31 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: vol.Optional(CONF_TURN_OFF): selector.ActionSelector(), } + if domain == Platform.UPDATE: + schema |= { + vol.Optional(CONF_INSTALLED_VERSION): selector.TemplateSelector(), + vol.Optional(CONF_LATEST_VERSION): selector.TemplateSelector(), + vol.Optional(CONF_INSTALL): selector.ActionSelector(), + vol.Optional(CONF_IN_PROGRESS): selector.TemplateSelector(), + vol.Optional(CONF_RELEASE_SUMMARY): selector.TemplateSelector(), + vol.Optional(CONF_RELEASE_URL): selector.TemplateSelector(), + vol.Optional(CONF_TITLE): selector.TemplateSelector(), + vol.Optional(CONF_UPDATE_PERCENTAGE): selector.TemplateSelector(), + vol.Optional(CONF_BACKUP): selector.BooleanSelector(), + vol.Optional(CONF_SPECIFIC_VERSION): selector.BooleanSelector(), + } + if flow_type == "config": + schema |= { + vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[cls.value for cls in UpdateDeviceClass], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="update_device_class", + sort=True, + ), + ), + } + if domain == Platform.VACUUM: schema |= _SCHEMA_STATE | { vol.Required(SERVICE_START): selector.ActionSelector(), @@ -470,6 +509,7 @@ TEMPLATE_TYPES = [ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, Platform.VACUUM, ] @@ -539,6 +579,11 @@ CONFIG_FLOW = { preview="template", validate_user_input=validate_user_input(Platform.SWITCH), ), + Platform.UPDATE: SchemaFlowFormStep( + config_schema(Platform.UPDATE), + preview="template", + validate_user_input=validate_user_input(Platform.UPDATE), + ), Platform.VACUUM: SchemaFlowFormStep( config_schema(Platform.VACUUM), preview="template", @@ -613,6 +658,11 @@ OPTIONS_FLOW = { preview="template", validate_user_input=validate_user_input(Platform.SWITCH), ), + Platform.UPDATE: SchemaFlowFormStep( + options_schema(Platform.UPDATE), + preview="template", + validate_user_input=validate_user_input(Platform.UPDATE), + ), Platform.VACUUM: SchemaFlowFormStep( options_schema(Platform.VACUUM), preview="template", @@ -635,6 +685,7 @@ CREATE_PREVIEW_ENTITY: dict[ Platform.SELECT: async_create_preview_select, Platform.SENSOR: async_create_preview_sensor, Platform.SWITCH: async_create_preview_switch, + Platform.UPDATE: async_create_preview_update, Platform.VACUUM: async_create_preview_vacuum, } diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 43b5fcc255a..5ff2c0137ac 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -47,6 +47,7 @@ PLATFORMS = [ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, Platform.VACUUM, Platform.WEATHER, ] diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 6de26d885cb..c565023f7de 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -393,6 +393,7 @@ "select": "Template a select", "sensor": "Template a sensor", "switch": "Template a switch", + "update": "Template an update", "vacuum": "Template a vacuum" }, "title": "Template helper" @@ -424,6 +425,48 @@ }, "title": "Template switch" }, + "update": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "device_class": "[%key:component::template::common::device_class%]", + "name": "[%key:common::config_flow::data::name%]", + "installed_version": "[%key:component::update::entity_component::_::state_attributes::installed_version::name%]", + "latest_version": "[%key:component::update::entity_component::_::state_attributes::latest_version::name%]", + "install": "Actions on install", + "in_progress": "[%key:component::update::entity_component::_::state_attributes::in_progress::name%]", + "release_summary": "[%key:component::update::entity_component::_::state_attributes::release_summary::name%]", + "release_url": "[%key:component::update::entity_component::_::state_attributes::release_url::name%]", + "title": "[%key:component::update::entity_component::_::state_attributes::title::name%]", + "backup": "Backup", + "specific_version": "Specific version", + "update_percent": "Update percentage" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]", + "installed_version": "Defines a template to get the installed version.", + "latest_version": "Defines a template to get the latest version.", + "install": "Defines actions to run when the update is installed. Receives variables `specific_version` and `backup` when enabled.", + "in_progress": "Defines a template to get the in-progress state.", + "release_summary": "Defines a template to get the release summary.", + "release_url": "Defines a template to get the release URL.", + "title": "Defines a template to get the update title.", + "backup": "Enable or disable the `automatic backup before update` option in the update repair. When disabled, the `backup` variable will always provide `False` during the `install` action and it will not accept the `backup` option.", + "specific_version": "Enable or disable using the `version` variable with the `install` action. When disabled, the `specific_version` variable will always provide `None` in the `install` actions", + "update_percent": "Defines a template to get the update completion percentage." + }, + "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 update" + }, "vacuum": { "data": { "device_id": "[%key:common::config_flow::data::device%]", @@ -853,6 +896,48 @@ }, "title": "[%key:component::template::config::step::switch::title%]" }, + "update": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "device_class": "[%key:component::template::common::device_class%]", + "name": "[%key:common::config_flow::data::name%]", + "installed_version": "[%key:component::update::entity_component::_::state_attributes::installed_version::name%]", + "latest_version": "[%key:component::update::entity_component::_::state_attributes::latest_version::name%]", + "install": "[%key:component::template::config::step::update::data::install%]", + "in_progress": "[%key:component::update::entity_component::_::state_attributes::in_progress::name%]", + "release_summary": "[%key:component::update::entity_component::_::state_attributes::release_summary::name%]", + "release_url": "[%key:component::update::entity_component::_::state_attributes::release_url::name%]", + "title": "[%key:component::update::entity_component::_::state_attributes::title::name%]", + "backup": "[%key:component::template::config::step::update::data::backup%]", + "specific_version": "[%key:component::template::config::step::update::data::specific_version%]", + "update_percent": "[%key:component::template::config::step::update::data::update_percent%]" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]", + "installed_version": "[%key:component::template::config::step::update::data_description::installed_version%]", + "latest_version": "[%key:component::template::config::step::update::data_description::latest_version%]", + "install": "[%key:component::template::config::step::update::data_description::install%]", + "in_progress": "[%key:component::template::config::step::update::data_description::in_progress%]", + "release_summary": "[%key:component::template::config::step::update::data_description::release_summary%]", + "release_url": "[%key:component::template::config::step::update::data_description::release_url%]", + "title": "[%key:component::template::config::step::update::data_description::title%]", + "backup": "[%key:component::template::config::step::update::data_description::backup%]", + "specific_version": "[%key:component::template::config::step::update::data_description::specific_version%]", + "update_percent": "[%key:component::template::config::step::update::data_description::update_percent%]" + }, + "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 update" + }, "vacuum": { "data": { "device_id": "[%key:common::config_flow::data::device%]", @@ -1037,6 +1122,11 @@ "options": { "none": "No unit of measurement" } + }, + "update_device_class": { + "options": { + "firmware": "[%key:component::update::entity_component::firmware::name%]" + } } }, "services": { diff --git a/homeassistant/components/template/update.py b/homeassistant/components/template/update.py new file mode 100644 index 00000000000..a6b0bca0f5f --- /dev/null +++ b/homeassistant/components/template/update.py @@ -0,0 +1,463 @@ +"""Support for updates which integrates with other components.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +import voluptuous as vol + +from homeassistant.components.update import ( + ATTR_INSTALLED_VERSION, + ATTR_LATEST_VERSION, + DEVICE_CLASSES_SCHEMA, + DOMAIN as UPDATE_DOMAIN, + ENTITY_ID_FORMAT, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_NAME, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) +from homeassistant.helpers.template import _SENTINEL +from homeassistant.helpers.trigger_template_entity import CONF_PICTURE +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import TriggerUpdateCoordinator +from .const import DOMAIN +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_schema, +) +from .trigger_entity import TriggerEntity + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Template Update" + +ATTR_BACKUP = "backup" +ATTR_SPECIFIC_VERSION = "specific_version" + +CONF_BACKUP = "backup" +CONF_IN_PROGRESS = "in_progress" +CONF_INSTALL = "install" +CONF_INSTALLED_VERSION = "installed_version" +CONF_LATEST_VERSION = "latest_version" +CONF_RELEASE_SUMMARY = "release_summary" +CONF_RELEASE_URL = "release_url" +CONF_SPECIFIC_VERSION = "specific_version" +CONF_TITLE = "title" +CONF_UPDATE_PERCENTAGE = "update_percentage" + +UPDATE_COMMON_SCHEMA = vol.Schema( + { + vol.Optional(CONF_BACKUP, default=False): cv.boolean, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_IN_PROGRESS): cv.template, + vol.Optional(CONF_INSTALL): cv.SCRIPT_SCHEMA, + vol.Required(CONF_INSTALLED_VERSION): cv.template, + vol.Required(CONF_LATEST_VERSION): cv.template, + vol.Optional(CONF_RELEASE_SUMMARY): cv.template, + vol.Optional(CONF_RELEASE_URL): cv.template, + vol.Optional(CONF_SPECIFIC_VERSION, default=False): cv.boolean, + vol.Optional(CONF_TITLE): cv.template, + vol.Optional(CONF_UPDATE_PERCENTAGE): cv.template, + } +) + +UPDATE_YAML_SCHEMA = UPDATE_COMMON_SCHEMA.extend( + make_template_entity_common_modern_schema(DEFAULT_NAME).schema +) + +UPDATE_CONFIG_ENTRY_SCHEMA = UPDATE_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 update.""" + await async_setup_template_platform( + hass, + UPDATE_DOMAIN, + config, + StateUpdateEntity, + TriggerUpdateEntity, + 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, + StateUpdateEntity, + UPDATE_CONFIG_ENTRY_SCHEMA, + ) + + +@callback +def async_create_preview_update( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> StateUpdateEntity: + """Create a preview.""" + return async_setup_template_preview( + hass, + name, + config, + StateUpdateEntity, + UPDATE_CONFIG_ENTRY_SCHEMA, + ) + + +class AbstractTemplateUpdate(AbstractTemplateEntity, UpdateEntity): + """Representation of a template update 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._installed_version_template = config[CONF_INSTALLED_VERSION] + self._latest_version_template = config[CONF_LATEST_VERSION] + + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + + self._in_progress_template = config.get(CONF_IN_PROGRESS) + self._release_summary_template = config.get(CONF_RELEASE_SUMMARY) + self._release_url_template = config.get(CONF_RELEASE_URL) + self._title_template = config.get(CONF_TITLE) + self._update_percentage_template = config.get(CONF_UPDATE_PERCENTAGE) + + self._attr_supported_features = UpdateEntityFeature(0) + if config[CONF_BACKUP]: + self._attr_supported_features |= UpdateEntityFeature.BACKUP + if config[CONF_SPECIFIC_VERSION]: + self._attr_supported_features |= UpdateEntityFeature.SPECIFIC_VERSION + if ( + self._in_progress_template is not None + or self._update_percentage_template is not None + ): + self._attr_supported_features |= UpdateEntityFeature.PROGRESS + + self._optimistic_in_process = ( + self._in_progress_template is None + and self._update_percentage_template is not None + ) + + @callback + def _update_installed_version(self, result: Any) -> None: + if result is None: + self._attr_installed_version = None + return + + self._attr_installed_version = cv.string(result) + + @callback + def _update_latest_version(self, result: Any) -> None: + if result is None: + self._attr_latest_version = None + return + + self._attr_latest_version = cv.string(result) + + @callback + def _update_in_process(self, result: Any) -> None: + try: + self._attr_in_progress = cv.boolean(result) + except vol.Invalid: + _LOGGER.error( + "Received invalid in_process value: %s for entity %s. Expected: True, False", + result, + self.entity_id, + ) + self._attr_in_progress = False + + @callback + def _update_release_summary(self, result: Any) -> None: + if result is None: + self._attr_release_summary = None + return + + self._attr_release_summary = cv.string(result) + + @callback + def _update_release_url(self, result: Any) -> None: + if result is None: + self._attr_release_url = None + return + + try: + self._attr_release_url = cv.url(result) + except vol.Invalid: + _LOGGER.error( + "Received invalid release_url: %s for entity %s", + result, + self.entity_id, + ) + self._attr_release_url = None + + @callback + def _update_title(self, result: Any) -> None: + if result is None: + self._attr_title = None + return + + self._attr_title = cv.string(result) + + @callback + def _update_update_percentage(self, result: Any) -> None: + if result is None: + if self._optimistic_in_process: + self._attr_in_progress = False + self._attr_update_percentage = None + return + + try: + percentage = vol.All( + vol.Coerce(float), + vol.Range(0, 100, min_included=True, max_included=True), + )(result) + if self._optimistic_in_process: + self._attr_in_progress = True + self._attr_update_percentage = percentage + except vol.Invalid: + _LOGGER.error( + "Received invalid update_percentage: %s for entity %s", + result, + self.entity_id, + ) + self._attr_update_percentage = None + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + await self.async_run_script( + self._action_scripts[CONF_INSTALL], + run_variables={ATTR_SPECIFIC_VERSION: version, ATTR_BACKUP: backup}, + context=self._context, + ) + + +class StateUpdateEntity(TemplateEntity, AbstractTemplateUpdate): + """Representation of a Template update.""" + + _attr_should_poll = False + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + unique_id: str | None, + ) -> None: + """Initialize the Template update.""" + TemplateEntity.__init__(self, hass, config, unique_id) + AbstractTemplateUpdate.__init__(self, config) + + name = self._attr_name + if TYPE_CHECKING: + assert name is not None + + # Scripts can be an empty list, therefore we need to check for None + if (install_action := config.get(CONF_INSTALL)) is not None: + self.add_script(CONF_INSTALL, install_action, name, DOMAIN) + self._attr_supported_features |= UpdateEntityFeature.INSTALL + + @property + def entity_picture(self) -> str | None: + """Return the entity picture to use in the frontend.""" + # This is needed to override the base update entity functionality + if self._attr_entity_picture is None: + # The default picture for update entities would use `self.platform.platform_name` in + # place of `template`. This does not work when creating an entity preview because + # the platform does not exist for that entity, therefore this is hardcoded as `template`. + return "https://brands.home-assistant.io/_/template/icon.png" + return self._attr_entity_picture + + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" + self.add_template_attribute( + "_attr_installed_version", + self._installed_version_template, + None, + self._update_installed_version, + none_on_template_error=True, + ) + self.add_template_attribute( + "_attr_latest_version", + self._latest_version_template, + None, + self._update_latest_version, + none_on_template_error=True, + ) + if self._in_progress_template is not None: + self.add_template_attribute( + "_attr_in_progress", + self._in_progress_template, + None, + self._update_in_process, + none_on_template_error=True, + ) + if self._release_summary_template is not None: + self.add_template_attribute( + "_attr_release_summary", + self._release_summary_template, + None, + self._update_release_summary, + none_on_template_error=True, + ) + if self._release_url_template is not None: + self.add_template_attribute( + "_attr_release_url", + self._release_url_template, + None, + self._update_release_url, + none_on_template_error=True, + ) + if self._title_template is not None: + self.add_template_attribute( + "_attr_title", + self._title_template, + None, + self._update_title, + none_on_template_error=True, + ) + if self._update_percentage_template is not None: + self.add_template_attribute( + "_attr_update_percentage", + self._update_percentage_template, + None, + self._update_update_percentage, + none_on_template_error=True, + ) + super()._async_setup_templates() + + +class TriggerUpdateEntity(TriggerEntity, AbstractTemplateUpdate): + """Update entity based on trigger data.""" + + domain = UPDATE_DOMAIN + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize the entity.""" + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateUpdate.__init__(self, config) + + for key in ( + CONF_INSTALLED_VERSION, + CONF_LATEST_VERSION, + ): + self._to_render_simple.append(key) + self._parse_result.add(key) + + # Scripts can be an empty list, therefore we need to check for None + if (install_action := config.get(CONF_INSTALL)) is not None: + self.add_script( + CONF_INSTALL, + install_action, + self._rendered.get(CONF_NAME, DEFAULT_NAME), + DOMAIN, + ) + self._attr_supported_features |= UpdateEntityFeature.INSTALL + + for key in ( + CONF_IN_PROGRESS, + CONF_RELEASE_SUMMARY, + CONF_RELEASE_URL, + CONF_TITLE, + CONF_UPDATE_PERCENTAGE, + ): + if isinstance(config.get(key), template.Template): + self._to_render_simple.append(key) + self._parse_result.add(key) + + # Ensure the entity picture can resolve None to produce the default picture. + if CONF_PICTURE in config: + self._parse_result.add(CONF_PICTURE) + + async def async_added_to_hass(self) -> None: + """Restore last state.""" + await super().async_added_to_hass() + if ( + (last_state := await self.async_get_last_state()) is not None + and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) + and self._attr_installed_version is None + and self._attr_latest_version is None + ): + self._attr_installed_version = last_state.attributes[ATTR_INSTALLED_VERSION] + self._attr_latest_version = last_state.attributes[ATTR_LATEST_VERSION] + self.restore_attributes(last_state) + + @property + def entity_picture(self) -> str | None: + """Return entity picture.""" + if (picture := self._rendered.get(CONF_PICTURE)) is None: + return UpdateEntity.entity_picture.fget(self) # type: ignore[attr-defined] + return picture + + @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 + + write_ha_state = False + for key, updater in ( + (CONF_INSTALLED_VERSION, self._update_installed_version), + (CONF_LATEST_VERSION, self._update_latest_version), + (CONF_IN_PROGRESS, self._update_in_process), + (CONF_RELEASE_SUMMARY, self._update_release_summary), + (CONF_RELEASE_URL, self._update_release_url), + (CONF_TITLE, self._update_title), + (CONF_UPDATE_PERCENTAGE, self._update_update_percentage), + ): + if (rendered := self._rendered.get(key, _SENTINEL)) is not _SENTINEL: + updater(rendered) + write_ha_state = True + + if len(self._rendered) > 0: + # In case any non optimistic template + write_ha_state = True + + if write_ha_state: + self.async_write_ha_state() diff --git a/tests/components/template/snapshots/test_update.ambr b/tests/components/template/snapshots/test_update.ambr new file mode 100644 index 00000000000..479ccb88ffc --- /dev/null +++ b/tests/components/template/snapshots/test_update.ambr @@ -0,0 +1,26 @@ +# serializer version: 1 +# name: test_setup_config_entry + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/template/icon.png', + 'friendly_name': 'template_update', + 'in_progress': False, + 'installed_version': '1.0', + 'latest_version': '2.0', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.template_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 49a9d5a1e5f..3bf7b836a8b 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -249,6 +249,16 @@ BINARY_SENSOR_OPTIONS = { {}, {}, ), + ( + "update", + {"installed_version": "{{ states('update.one') }}"}, + "off", + {"one": "2.0", "two": "1.0"}, + {}, + {"latest_version": "{{ '2.0' }}"}, + {"latest_version": "{{ '2.0' }}"}, + {}, + ), ( "vacuum", {"state": "{{ states('vacuum.one') }}"}, @@ -440,6 +450,12 @@ async def test_config_flow( {"options": "{{ ['off', 'on', 'auto'] }}"}, {"options": "{{ ['off', 'on', 'auto'] }}"}, ), + ( + "update", + {"installed_version": "{{ states('update.one') }}"}, + {"latest_version": "{{ '2.0' }}"}, + {"latest_version": "{{ '2.0' }}"}, + ), ( "vacuum", {"state": "{{ states('vacuum.one') }}"}, @@ -715,6 +731,16 @@ async def test_config_flow_device( {}, "value_template", ), + ( + "update", + {"installed_version": "{{ states('update.one') }}"}, + {"installed_version": "{{ states('update.two') }}"}, + ["off", "on"], + {"one": "2.0", "two": "1.0"}, + {"latest_version": "{{ '2.0' }}"}, + {"latest_version": "{{ '2.0' }}"}, + "installed_version", + ), ( "vacuum", {"state": "{{ states('vacuum.one') }}"}, @@ -1570,6 +1596,12 @@ async def test_option_flow_sensor_preview_config_entry_removed( {}, {}, ), + ( + "update", + {"installed_version": "{{ states('update.one') }}"}, + {"latest_version": "{{ '2.0' }}"}, + {"latest_version": "{{ '2.0' }}"}, + ), ( "vacuum", {"state": "{{ states('vacuum.one') }}"}, diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index 8efca13a218..a95bf2a6332 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -376,6 +376,18 @@ async def async_yaml_patch_helper(hass: HomeAssistant, filename: str) -> None: "event_types": "{{ ['single', 'double'] }}", }, ), + ( + { + "template_type": "update", + "name": "My template", + "latest_version": "{{ '1.0' }}", + "installed_version": "{{ '1.0' }}", + }, + { + "latest_version": "{{ '1.0' }}", + "installed_version": "{{ '1.0' }}", + }, + ), ], ) async def test_change_device( diff --git a/tests/components/template/test_update.py b/tests/components/template/test_update.py new file mode 100644 index 00000000000..61fbfeede7a --- /dev/null +++ b/tests/components/template/test_update.py @@ -0,0 +1,1085 @@ +"""The tests for the Template update platform.""" + +from typing import Any + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import template, update +from homeassistant.const import ( + ATTR_ENTITY_PICTURE, + ATTR_ICON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant, ServiceCall, State +from homeassistant.exceptions import HomeAssistantError +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, + async_setup_modern_state_format, + async_setup_modern_trigger_format, + make_test_trigger, +) + +from tests.common import ( + MockConfigEntry, + assert_setup_component, + mock_restore_cache_with_extra_data, +) +from tests.conftest import WebSocketGenerator + +TEST_OBJECT_ID = "template_update" +TEST_ENTITY_ID = f"update.{TEST_OBJECT_ID}" +TEST_INSTALLED_SENSOR = "sensor.installed_update" +TEST_LATEST_SENSOR = "sensor.latest_update" +TEST_SENSOR_ID = "sensor.test_update" +TEST_STATE_TRIGGER = make_test_trigger( + TEST_INSTALLED_SENSOR, TEST_LATEST_SENSOR, TEST_SENSOR_ID +) +TEST_INSTALLED_TEMPLATE = "{{ '1.0' }}" +TEST_LATEST_TEMPLATE = "{{ '2.0' }}" + +TEST_UPDATE_CONFIG = { + "installed_version": TEST_INSTALLED_TEMPLATE, + "latest_version": TEST_LATEST_TEMPLATE, +} +TEST_UNIQUE_ID_CONFIG = { + **TEST_UPDATE_CONFIG, + "unique_id": "not-so-unique-anymore", +} + +INSTALL_ACTION = { + "install": { + "action": "test.automation", + "data": { + "caller": "{{ this.entity_id }}", + "action": "install", + "backup": "{{ backup }}", + "specific_version": "{{ specific_version }}", + }, + } +} + + +async def async_setup_config( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + config: dict[str, Any], + extra_config: dict[str, Any] | None, +) -> None: + """Do setup of update integration.""" + config = {**config, **extra_config} if extra_config else config + if style == ConfigurationStyle.MODERN: + await async_setup_modern_state_format(hass, update.DOMAIN, count, config) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_modern_trigger_format( + hass, update.DOMAIN, TEST_STATE_TRIGGER, count, config + ) + + +@pytest.fixture +async def setup_base( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + config: dict[str, Any], +) -> None: + """Do setup of update integration.""" + await async_setup_config( + hass, + count, + style, + config, + None, + ) + + +@pytest.fixture +async def setup_update( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + installed_template: str, + latest_template: str, + extra_config: dict[str, Any] | None, +) -> None: + """Do setup of update integration.""" + await async_setup_config( + hass, + count, + style, + { + "name": TEST_OBJECT_ID, + "installed_version": installed_template, + "latest_version": latest_template, + }, + extra_config, + ) + + +@pytest.fixture +async def setup_single_attribute_update( + hass: HomeAssistant, + style: ConfigurationStyle, + installed_template: str, + latest_template: str, + attribute: str, + attribute_template: str, +) -> None: + """Do setup of update platform testing a single attribute.""" + await async_setup_config( + hass, + 1, + style, + { + "name": TEST_OBJECT_ID, + "installed_version": installed_template, + "latest_version": latest_template, + }, + {attribute: attribute_template} if attribute and attribute_template else {}, + ) + + +async def test_legacy_platform_config(hass: HomeAssistant) -> None: + """Test a legacy platform does not create update entities.""" + with assert_setup_component(1, update.DOMAIN): + assert await async_setup_component( + hass, + update.DOMAIN, + {"update": {"platform": "template", "updates": {TEST_OBJECT_ID: {}}}}, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + assert hass.states.async_all("update") == [] + + +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test the config flow.""" + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": TEST_OBJECT_ID, + "template_type": update.DOMAIN, + **TEST_UPDATE_CONFIG, + }, + 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 + + +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": TEST_OBJECT_ID, + "template_type": update.DOMAIN, + **TEST_UPDATE_CONFIG, + "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(TEST_ENTITY_ID) + assert template_entity is not None + assert template_entity.device_id == device_entry.id + + +@pytest.mark.parametrize(("count", "extra_config"), [(1, None)]) +@pytest.mark.parametrize( + ("style", "expected_state"), + [ + (ConfigurationStyle.MODERN, STATE_UNKNOWN), + (ConfigurationStyle.TRIGGER, STATE_UNKNOWN), + ], +) +@pytest.mark.parametrize( + ("installed_template", "latest_template"), + [ + ("{{states.test['big.fat...']}}", TEST_LATEST_TEMPLATE), + (TEST_INSTALLED_TEMPLATE, "{{states.test['big.fat...']}}"), + ("{{states.test['big.fat...']}}", "{{states.test['big.fat...']}}"), + ], +) +@pytest.mark.usefixtures("setup_update") +async def test_syntax_error( + hass: HomeAssistant, + expected_state: str, +) -> None: + """Test template update with render error.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("count", "extra_config", "installed_template", "latest_template"), + [ + ( + 1, + None, + "{{ states('sensor.installed_update') }}", + "{{ states('sensor.latest_update') }}", + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("installed", "latest", "expected"), + [ + ("1.0", "2.0", STATE_ON), + ("2.0", "2.0", STATE_OFF), + ], +) +@pytest.mark.usefixtures("setup_update") +async def test_update_templates( + hass: HomeAssistant, installed: str, latest: str, expected: str +) -> None: + """Test update template.""" + hass.states.async_set(TEST_INSTALLED_SENSOR, installed) + hass.states.async_set(TEST_LATEST_SENSOR, latest) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == expected + assert state.attributes["installed_version"] == installed + assert state.attributes["latest_version"] == latest + + # ensure that the entity picture exists when not provided. + assert ( + state.attributes["entity_picture"] + == "https://brands.home-assistant.io/_/template/icon.png" + ) + + +@pytest.mark.parametrize( + ("count", "extra_config", "installed_template", "latest_template"), + [ + ( + 1, + None, + "{{ states('sensor.installed_update') }}", + "{{ states('sensor.latest_update') }}", + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_update") +async def test_installed_and_latest_template_updates_from_entity( + hass: HomeAssistant, +) -> None: + """Test template installed and latest version templates updates from entities.""" + hass.states.async_set(TEST_INSTALLED_SENSOR, "1.0") + hass.states.async_set(TEST_LATEST_SENSOR, "2.0") + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + assert state.attributes["installed_version"] == "1.0" + assert state.attributes["latest_version"] == "2.0" + + hass.states.async_set(TEST_INSTALLED_SENSOR, "2.0") + hass.states.async_set(TEST_LATEST_SENSOR, "2.0") + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + assert state.attributes["installed_version"] == "2.0" + assert state.attributes["latest_version"] == "2.0" + + hass.states.async_set(TEST_INSTALLED_SENSOR, "2.0") + hass.states.async_set(TEST_LATEST_SENSOR, "3.0") + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + assert state.attributes["installed_version"] == "2.0" + assert state.attributes["latest_version"] == "3.0" + + +@pytest.mark.parametrize( + ("count", "extra_config", "latest_template"), + [(1, None, TEST_LATEST_TEMPLATE)], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("installed_template", "expected", "expected_attr"), + [ + ("{{ '1.0' }}", STATE_ON, "1.0"), + ("{{ 1.0 }}", STATE_ON, "1.0"), + ("{{ '2.0' }}", STATE_OFF, "2.0"), + ("{{ 2.0 }}", STATE_OFF, "2.0"), + ("{{ None }}", STATE_UNKNOWN, None), + ("{{ 'foo' }}", STATE_ON, "foo"), + ("{{ x + 2 }}", STATE_UNKNOWN, None), + ], +) +@pytest.mark.usefixtures("setup_update") +async def test_installed_version_template( + hass: HomeAssistant, expected: str, expected_attr: Any +) -> None: + """Test installed_version template results.""" + # Ensure trigger based template entities update + hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == expected + assert state.attributes["installed_version"] == expected_attr + + +@pytest.mark.parametrize( + ("count", "extra_config", "installed_template"), + [(1, None, TEST_INSTALLED_TEMPLATE)], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("latest_template", "expected", "expected_attr"), + [ + ("{{ '1.0' }}", STATE_OFF, "1.0"), + ("{{ 1.0 }}", STATE_OFF, "1.0"), + ("{{ '2.0' }}", STATE_ON, "2.0"), + ("{{ 2.0 }}", STATE_ON, "2.0"), + ("{{ None }}", STATE_UNKNOWN, None), + ("{{ 'foo' }}", STATE_ON, "foo"), + ("{{ x + 2 }}", STATE_UNKNOWN, None), + ], +) +@pytest.mark.usefixtures("setup_update") +async def test_latest_version_template( + hass: HomeAssistant, expected: str, expected_attr: Any +) -> None: + """Test latest_version template results.""" + # Ensure trigger based template entities update + hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == expected + assert state.attributes["latest_version"] == expected_attr + + +@pytest.mark.parametrize( + ("count", "extra_config", "installed_template", "latest_template"), + [ + ( + 1, + INSTALL_ACTION, + "{{ states('sensor.installed_update') }}", + "{{ states('sensor.latest_update') }}", + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_update") +async def test_install_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: + """Test install action.""" + + hass.states.async_set(TEST_INSTALLED_SENSOR, "1.0") + hass.states.async_set(TEST_LATEST_SENSOR, "2.0") + await hass.async_block_till_done() + + await hass.services.async_call( + update.DOMAIN, + update.SERVICE_INSTALL, + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + # verify + assert len(calls) == 1 + assert calls[-1].data["action"] == "install" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + + hass.states.async_set(TEST_INSTALLED_SENSOR, "2.0") + hass.states.async_set(TEST_LATEST_SENSOR, "2.0") + await hass.async_block_till_done() + + # Ensure an error is raised when there's no update. + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + update.DOMAIN, + update.SERVICE_INSTALL, + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + # verify + assert len(calls) == 1 + assert calls[-1].data["action"] == "install" + assert calls[-1].data["caller"] == TEST_ENTITY_ID + + +@pytest.mark.parametrize( + ("installed_template", "latest_template"), + [(TEST_INSTALLED_TEMPLATE, TEST_LATEST_TEMPLATE)], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("attribute", "attribute_template", "key", "expected"), + [ + ( + "picture", + "{% if is_state('sensor.installed_update', 'on') %}something{% endif %}", + ATTR_ENTITY_PICTURE, + "something", + ), + ( + "icon", + "{% if is_state('sensor.installed_update', 'on') %}mdi:something{% endif %}", + ATTR_ICON, + "mdi:something", + ), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_update") +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_INSTALLED_SENSOR, STATE_OFF) + 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_INSTALLED_SENSOR, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + + assert state.attributes[key] == expected + + +@pytest.mark.parametrize( + ("installed_template", "latest_template"), + [(TEST_INSTALLED_TEMPLATE, TEST_LATEST_TEMPLATE)], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("attribute", "attribute_template"), + [ + ( + "picture", + "{{ 'foo.png' if is_state('sensor.installed_update', 'on') else None }}", + ), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_update") +async def test_entity_picture_uses_default(hass: HomeAssistant) -> None: + """Test entity picture when template resolves None.""" + state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes[ATTR_ENTITY_PICTURE] == "foo.png" + + state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + + assert ( + state.attributes[ATTR_ENTITY_PICTURE] + == "https://brands.home-assistant.io/_/template/icon.png" + ) + + +@pytest.mark.parametrize( + ("installed_template", "latest_template", "attribute"), + [(TEST_INSTALLED_TEMPLATE, TEST_LATEST_TEMPLATE, "in_progress")], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("attribute_template", "expected", "error"), + [ + ("{{ True }}", True, None), + ("{{ False }}", False, None), + ("{{ None }}", False, "Received invalid in_process value: None"), + ( + "{{ 'foo' }}", + False, + "Received invalid in_process value: foo", + ), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_update") +async def test_in_process_template( + hass: HomeAssistant, + attribute: str, + expected: Any, + error: str | None, + caplog: pytest.LogCaptureFixture, + caplog_setup_text: str, +) -> None: + """Test in process templates.""" + # Ensure trigger entities trigger. + state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get(attribute) == expected + + assert error is None or error in caplog_setup_text or error in caplog.text + + +@pytest.mark.parametrize( + ( + "installed_template", + "latest_template", + ), + [(TEST_INSTALLED_TEMPLATE, TEST_LATEST_TEMPLATE)], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize("attribute", ["release_summary", "title"]) +@pytest.mark.parametrize( + ("attribute_template", "expected"), + [ + ("{{ True }}", "True"), + ("{{ False }}", "False"), + ("{{ None }}", None), + ("{{ 'foo' }}", "foo"), + ("{{ 1.0 }}", "1.0"), + ("{{ x + 2 }}", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_update") +async def test_release_summary_and_title_templates( + hass: HomeAssistant, + attribute: str, + expected: Any, +) -> None: + """Test release summary and title templates.""" + # Ensure trigger entities trigger. + state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get(attribute) == expected + + +@pytest.mark.parametrize( + ("installed_template", "latest_template", "attribute"), + [(TEST_INSTALLED_TEMPLATE, TEST_LATEST_TEMPLATE, "release_url")], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("attribute_template", "expected", "error"), + [ + ("{{ 'http://foo.bar' }}", "http://foo.bar", None), + ("{{ 'https://foo.bar' }}", "https://foo.bar", None), + ("{{ None }}", None, None), + ( + "{{ '/local/thing' }}", + None, + "Received invalid release_url: /local/thing", + ), + ( + "{{ 'foo' }}", + None, + "Received invalid release_url: foo", + ), + ( + "{{ 1.0 }}", + None, + "Received invalid release_url: 1", + ), + ( + "{{ True }}", + None, + "Received invalid release_url: True", + ), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_update") +async def test_release_url_template( + hass: HomeAssistant, + attribute: str, + expected: Any, + error: str | None, + caplog: pytest.LogCaptureFixture, + caplog_setup_text: str, +) -> None: + """Test release url templates.""" + # Ensure trigger entities trigger. + state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get(attribute) == expected + + assert error is None or error in caplog_setup_text or error in caplog.text + + +@pytest.mark.parametrize( + ("installed_template", "latest_template", "attribute"), + [(TEST_INSTALLED_TEMPLATE, TEST_LATEST_TEMPLATE, "update_percentage")], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("attribute_template", "expected", "error"), + [ + ("{{ 100 }}", 100, None), + ("{{ 0 }}", 0, None), + ("{{ 45 }}", 45, None), + ("{{ None }}", None, None), + ("{{ -1 }}", None, "Received invalid update_percentage: -1"), + ("{{ 101 }}", None, "Received invalid update_percentage: 101"), + ("{{ 'foo' }}", None, "Received invalid update_percentage: foo"), + ("{{ x - 4 }}", None, "UndefinedError: 'x' is undefined"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_update") +async def test_update_percent_template( + hass: HomeAssistant, + attribute: str, + expected: Any, + error: str | None, + caplog: pytest.LogCaptureFixture, + caplog_setup_text: str, +) -> None: + """Test update percent templates.""" + # Ensure trigger entities trigger. + state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get(attribute) == expected + + assert error is None or error in caplog_setup_text or error in caplog.text + + +@pytest.mark.parametrize( + ("installed_template", "latest_template", "attribute", "attribute_template"), + [ + ( + TEST_INSTALLED_TEMPLATE, + TEST_LATEST_TEMPLATE, + "update_percentage", + "{% set e = 'sensor.test_update' %}{{ states(e) if e | has_value else None }}", + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_single_attribute_update") +async def test_optimistic_in_progress_with_update_percent_template( + hass: HomeAssistant, +) -> None: + """Test optimistic in_progress attribute with update percent templates.""" + # Ensure trigger entities trigger. + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["in_progress"] is False + assert state.attributes["update_percentage"] is None + + for i in range(101): + state = hass.states.async_set(TEST_SENSOR_ID, i) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["in_progress"] is True + assert state.attributes["update_percentage"] == i + + state = hass.states.async_set(TEST_SENSOR_ID, STATE_UNAVAILABLE) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["in_progress"] is False + assert state.attributes["update_percentage"] is None + + +@pytest.mark.parametrize( + ( + "count", + "installed_template", + "latest_template", + ), + [(1, TEST_INSTALLED_TEMPLATE, TEST_LATEST_TEMPLATE)], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ( + "extra_config", + "supported_feature", + "action_data", + "expected_backup", + "expected_version", + ), + [ + ( + {"backup": True, **INSTALL_ACTION}, + update.UpdateEntityFeature.BACKUP | update.UpdateEntityFeature.INSTALL, + {"backup": True}, + True, + None, + ), + ( + {"specific_version": True, **INSTALL_ACTION}, + update.UpdateEntityFeature.SPECIFIC_VERSION + | update.UpdateEntityFeature.INSTALL, + {"version": "v2.0"}, + False, + "v2.0", + ), + ( + {"backup": True, "specific_version": True, **INSTALL_ACTION}, + update.UpdateEntityFeature.SPECIFIC_VERSION + | update.UpdateEntityFeature.BACKUP + | update.UpdateEntityFeature.INSTALL, + {"backup": True, "version": "v2.0"}, + True, + "v2.0", + ), + (INSTALL_ACTION, update.UpdateEntityFeature.INSTALL, {}, False, None), + ], +) +@pytest.mark.usefixtures("setup_update") +async def test_supported_features( + hass: HomeAssistant, + supported_feature: update.UpdateEntityFeature, + action_data: dict, + calls: list[ServiceCall], + expected_backup: bool, + expected_version: str | None, +) -> None: + """Test release summary and title templates.""" + # Ensure trigger entities trigger. + state = hass.states.async_set(TEST_INSTALLED_SENSOR, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["supported_features"] == supported_feature + + await hass.services.async_call( + update.DOMAIN, + update.SERVICE_INSTALL, + {"entity_id": TEST_ENTITY_ID, **action_data}, + blocking=True, + ) + await hass.async_block_till_done() + + # verify + assert len(calls) == 1 + data = calls[-1].data + assert data["action"] == "install" + assert data["caller"] == TEST_ENTITY_ID + assert data["backup"] == expected_backup + assert data["specific_version"] == expected_version + + +@pytest.mark.parametrize( + ("installed_template", "latest_template", "attribute", "attribute_template"), + [ + ( + TEST_INSTALLED_TEMPLATE, + TEST_LATEST_TEMPLATE, + "availability", + "{{ 'sensor.test_update' | has_value }}", + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_single_attribute_update") +async def test_available_template_with_entities(hass: HomeAssistant) -> None: + """Test availability templates with values from other entities.""" + hass.states.async_set(TEST_SENSOR_ID, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state != STATE_UNAVAILABLE + + hass.states.async_set(TEST_SENSOR_ID, STATE_UNAVAILABLE) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set(TEST_SENSOR_ID, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("installed_template", "latest_template", "attribute", "attribute_template"), + [ + ( + TEST_INSTALLED_TEMPLATE, + TEST_LATEST_TEMPLATE, + "availability", + "{{ x - 12 }}", + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_single_attribute_update") +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.""" + # Ensure entity triggers + hass.states.async_set(TEST_SENSOR_ID, "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", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "update": { + "name": TEST_OBJECT_ID, + "installed_version": "{{ trigger.event.data.action }}", + "latest_version": "{{ '1.0.2' }}", + "picture": "{{ '/local/dogs.png' }}", + "icon": "{{ 'mdi:pirate' }}", + }, + }, + }, + ], +) +async def test_trigger_entity_restore_state( + hass: HomeAssistant, + count: int, + domain: str, + config: dict, +) -> None: + """Test restoring trigger entities.""" + restored_attributes = { + "installed_version": "1.0.0", + "latest_version": "1.0.1", + "entity_picture": "/local/cats.png", + "icon": "mdi:ship", + "skipped_version": "1.0.1", + } + fake_state = State( + TEST_ENTITY_ID, + STATE_OFF, + restored_attributes, + ) + mock_restore_cache_with_extra_data(hass, ((fake_state, {}),)) + 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() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + for attr, value in restored_attributes.items(): + assert state.attributes[attr] == value + + hass.bus.async_fire("test_event", {"action": "1.0.0"}) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes["icon"] == "mdi:pirate" + assert state.attributes["entity_picture"] == "/local/dogs.png" + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("updates", "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, updates: list[dict], style: ConfigurationStyle +) -> None: + """Test unique_id option only creates one update entity per id.""" + config = {"update": updates} + 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("update")) == 1 + + +async def test_nested_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test unique_id option creates one update entity per nested id.""" + + with assert_setup_component(1, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + { + "template": { + "unique_id": "x", + "update": [ + { + "name": "test_a", + **TEST_UPDATE_CONFIG, + "unique_id": "a", + }, + { + "name": "test_b", + **TEST_UPDATE_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("update")) == 2 + + entry = entity_registry.async_get("update.test_a") + assert entry + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get("update.test_b") + assert entry + assert entry.unique_id == "x-b" + + +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, + update.DOMAIN, + {"name": "My template", **TEST_UPDATE_CONFIG}, + ) + + assert state["state"] == STATE_ON + assert state["attributes"]["installed_version"] == "1.0" + assert state["attributes"]["latest_version"] == "2.0"