From 83c41c265d02424f7152ba958ac5bf6a7dc00aae Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 10 Feb 2026 05:39:29 -0500 Subject: [PATCH] Update template update to new template entity framework (#162561) --- homeassistant/components/template/update.py | 273 ++++-------------- .../components/template/validators.py | 53 ++++ tests/components/template/test_update.py | 18 +- tests/components/template/test_validators.py | 97 +++++++ 4 files changed, 220 insertions(+), 221 deletions(-) diff --git a/homeassistant/components/template/update.py b/homeassistant/components/template/update.py index 34dfa4901fa..7b03d606aaf 100644 --- a/homeassistant/components/template/update.py +++ b/homeassistant/components/template/update.py @@ -24,16 +24,15 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers import config_validation as cv 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 . import TriggerUpdateCoordinator, validators as template_validators from .const import DOMAIN from .entity import AbstractTemplateEntity from .helpers import ( @@ -145,19 +144,49 @@ class AbstractTemplateUpdate(AbstractTemplateEntity, UpdateEntity): # 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 + def __init__(self, name: str, 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) + # Setup templates. + self.setup_template( + CONF_INSTALLED_VERSION, + "_attr_installed_version", + template_validators.string(self, CONF_INSTALLED_VERSION), + ) + self.setup_template( + CONF_LATEST_VERSION, + "_attr_latest_version", + template_validators.string(self, CONF_LATEST_VERSION), + ) + self.setup_template( + CONF_IN_PROGRESS, + "_attr_in_progress", + template_validators.boolean(self, CONF_IN_PROGRESS), + self._update_in_progress, + ) + self.setup_template( + CONF_RELEASE_SUMMARY, + "_attr_release_summary", + template_validators.string(self, CONF_RELEASE_SUMMARY), + ) + self.setup_template( + CONF_RELEASE_URL, + "_attr_release_url", + template_validators.url(self, CONF_RELEASE_URL), + ) + self.setup_template( + CONF_TITLE, + "_attr_title", + template_validators.string(self, CONF_TITLE), + ) + self.setup_template( + CONF_UPDATE_PERCENTAGE, + "_attr_update_percentage", + template_validators.number(self, CONF_UPDATE_PERCENTAGE, 0.0, 100.0), + self._update_update_percentage, + ) self._attr_supported_features = UpdateEntityFeature(0) if config[CONF_BACKUP]: @@ -165,99 +194,40 @@ class AbstractTemplateUpdate(AbstractTemplateEntity, UpdateEntity): 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 + CONF_IN_PROGRESS in self._templates + or CONF_UPDATE_PERCENTAGE in self._templates ): self._attr_supported_features |= UpdateEntityFeature.PROGRESS self._optimistic_in_process = ( - self._in_progress_template is None - and self._update_percentage_template is not None + CONF_IN_PROGRESS not in self._templates + and CONF_UPDATE_PERCENTAGE in self._templates ) + # 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 + @callback - def _update_installed_version(self, result: Any) -> None: + def _update_in_progress(self, result: bool | None) -> 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, + template_validators.log_validation_result_error( + self, CONF_IN_PROGRESS, result, "expected a boolean" ) - self._attr_in_progress = False + self._attr_in_progress = result or 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: + def _update_update_percentage(self, result: float | None) -> 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 + if self._optimistic_in_process: + self._attr_in_progress = True + self._attr_update_percentage = result async def async_install( self, version: str | None, backup: bool, **kwargs: Any @@ -283,16 +253,10 @@ class StateUpdateEntity(TemplateEntity, AbstractTemplateUpdate): ) -> 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 + AbstractTemplateUpdate.__init__(self, name, config) @property def entity_picture(self) -> str | None: @@ -305,65 +269,6 @@ class StateUpdateEntity(TemplateEntity, AbstractTemplateUpdate): 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.""" @@ -378,35 +283,8 @@ class TriggerUpdateEntity(TriggerEntity, AbstractTemplateUpdate): ) -> 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) + name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + AbstractTemplateUpdate.__init__(self, name, config) # Ensure the entity picture can resolve None to produce the default picture. if CONF_PICTURE in config: @@ -431,32 +309,3 @@ class TriggerUpdateEntity(TriggerEntity, AbstractTemplateUpdate): 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: - 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/homeassistant/components/template/validators.py b/homeassistant/components/template/validators.py index 2b4336727f5..6169e3e6aa9 100644 --- a/homeassistant/components/template/validators.py +++ b/homeassistant/components/template/validators.py @@ -308,3 +308,56 @@ def item_in_list[T]( return result return convert + + +def url( + entity: Entity, + attribute: str, + **kwargs: Any, +) -> Callable[[Any], str | None]: + """Convert the result to a string url or None.""" + + def convert(result: Any) -> str | None: + if check_result_for_none(result, **kwargs): + return None + + try: + return cv.url(result) + except vol.Invalid: + log_validation_result_error( + entity, + attribute, + result, + "expected a url", + ) + return None + + return convert + + +def string( + entity: Entity, + attribute: str, + **kwargs: Any, +) -> Callable[[Any], str | None]: + """Convert the result to a string or None.""" + + def convert(result: Any) -> str | None: + if check_result_for_none(result, **kwargs): + return None + + if isinstance(result, str): + return result + + try: + return cv.string(result) + except vol.Invalid: + log_validation_result_error( + entity, + attribute, + result, + "expected a string", + ) + return None + + return convert diff --git a/tests/components/template/test_update.py b/tests/components/template/test_update.py index cc2a46c5efe..104cde73494 100644 --- a/tests/components/template/test_update.py +++ b/tests/components/template/test_update.py @@ -540,11 +540,11 @@ async def test_entity_picture_uses_default(hass: HomeAssistant) -> None: [ ("{{ True }}", True, None), ("{{ False }}", False, None), - ("{{ None }}", False, "Received invalid in_process value: None"), + ("{{ None }}", False, "Received invalid update in_progress: None"), ( "{{ 'foo' }}", False, - "Received invalid in_process value: foo", + "Received invalid update in_progress: foo", ), ], ) @@ -621,22 +621,22 @@ async def test_release_summary_and_title_templates( ( "{{ '/local/thing' }}", None, - "Received invalid release_url: /local/thing", + "Received invalid update release_url: /local/thing", ), ( "{{ 'foo' }}", None, - "Received invalid release_url: foo", + "Received invalid update release_url: foo", ), ( "{{ 1.0 }}", None, - "Received invalid release_url: 1", + "Received invalid update release_url: 1", ), ( "{{ True }}", None, - "Received invalid release_url: True", + "Received invalid update release_url: True", ), ], ) @@ -674,9 +674,9 @@ async def test_release_url_template( ("{{ 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"), + ("{{ -1 }}", None, "Received invalid update update_percentage: -1"), + ("{{ 101 }}", None, "Received invalid update update_percentage: 101"), + ("{{ 'foo' }}", None, "Received invalid update update_percentage: foo"), ("{{ x - 4 }}", None, "UndefinedError: 'x' is undefined"), ], ) diff --git a/tests/components/template/test_validators.py b/tests/components/template/test_validators.py index 2cbb13b47b5..8b198871c64 100644 --- a/tests/components/template/test_validators.py +++ b/tests/components/template/test_validators.py @@ -922,3 +922,100 @@ async def test_empty_items_in_list( value = cv.item_in_list(entity, "state", the_list, "bar")(value) assert value == expected check_for_error(value, expected, caplog.text, error.format(value)) + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ( + {"default_entity_id": "test.test"}, + "Received invalid test state: {} for entity test.test, expected a url", + ), + ( + {}, + "Received invalid state: {} for entity Test, expected a url", + ), + ], +) +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("http://foo.bar", "http://foo.bar"), + ("https://foo.bar", "https://foo.bar"), + *expect_none( + None, + "/local/thing", + "beeal;dfj", + "unknown", + "unavailable", + "tru", # codespell:ignore tru + "7", + "-1", + True, + False, + 1, + 1.0, + {}, + {"junk": "stuff"}, + {"junk"}, + [], + ["stuff"], + ), + ], +) +async def test_url( + hass: HomeAssistant, + config: dict, + error: str, + value: Any, + expected: bool | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test enum validator.""" + entity = create_test_entity(hass, config) + assert cv.url(entity, "state")(value) == expected + check_for_error(value, expected, caplog.text, error.format(value)) + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ( + {"default_entity_id": "test.test"}, + "Received invalid test state: {} for entity test.test, expected a string", + ), + ( + {}, + "Received invalid state: {} for entity Test, expected a string", + ), + ], +) +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("a string", "a string"), + (True, "True"), + (False, "False"), + (1, "1"), + (1.0, "1.0"), + *expect_none( + None, + {}, + {"junk": "stuff"}, + [], + ["stuff"], + ), + ], +) +async def test_string( + hass: HomeAssistant, + config: dict, + error: str, + value: Any, + expected: bool | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test enum validator.""" + entity = create_test_entity(hass, config) + assert cv.string(entity, "state")(value) == expected + check_for_error(value, expected, caplog.text, error.format(value))