1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-14 23:28:42 +00:00

Update template update to new template entity framework (#162561)

This commit is contained in:
Petro31
2026-02-10 05:39:29 -05:00
committed by GitHub
parent c8bc5618dc
commit 83c41c265d
4 changed files with 220 additions and 221 deletions

View File

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

View File

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

View File

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

View File

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