diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 5a5527cc7c5..0f8a36a8e31 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -24,6 +24,8 @@ from homeassistant.const import ( CONF_NAME, CONF_STATE, CONF_UNIT_OF_MEASUREMENT, + CONF_URL, + CONF_VERIFY_SSL, Platform, ) from homeassistant.core import HomeAssistant, callback @@ -83,6 +85,13 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: ) } + if domain == Platform.IMAGE: + schema |= { + vol.Required(CONF_URL): selector.TemplateSelector(), + vol.Optional(CONF_VERIFY_SSL, default=True): selector.BooleanSelector(), + vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), + } + if domain == Platform.SENSOR: schema |= _SCHEMA_STATE | { vol.Optional(CONF_UNIT_OF_MEASUREMENT): selector.SelectSelector( @@ -213,6 +222,7 @@ def validate_user_input( TEMPLATE_TYPES = [ "binary_sensor", "button", + "image", "sensor", ] @@ -227,6 +237,10 @@ CONFIG_FLOW = { config_schema(Platform.BUTTON), validate_user_input=validate_user_input(Platform.BUTTON), ), + Platform.IMAGE: SchemaFlowFormStep( + config_schema(Platform.IMAGE), + validate_user_input=validate_user_input(Platform.IMAGE), + ), Platform.SENSOR: SchemaFlowFormStep( config_schema(Platform.SENSOR), preview="template", @@ -246,6 +260,10 @@ OPTIONS_FLOW = { options_schema(Platform.BUTTON), validate_user_input=validate_user_input(Platform.BUTTON), ), + Platform.IMAGE: SchemaFlowFormStep( + options_schema(Platform.IMAGE), + validate_user_input=validate_user_input(Platform.IMAGE), + ), Platform.SENSOR: SchemaFlowFormStep( options_schema(Platform.SENSOR), preview="template", diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index 92f0fe7b9fa..ba85418c339 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -8,10 +8,18 @@ from typing import Any import voluptuous as vol from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN, ImageEntity -from homeassistant.const import CONF_UNIQUE_ID, CONF_URL, CONF_VERIFY_SSL +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_NAME, + CONF_UNIQUE_ID, + CONF_URL, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -35,6 +43,16 @@ IMAGE_SCHEMA = vol.Schema( ).extend(make_template_entity_common_schema(DEFAULT_NAME).schema) +IMAGE_CONFIG_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): cv.template, + vol.Required(CONF_URL): cv.template, + vol.Optional(CONF_VERIFY_SSL, default=True): bool, + vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), + } +) + + async def _async_create_entities( hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None ) -> list[StateImageEntity]: @@ -75,6 +93,20 @@ async def async_setup_platform( ) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize config entry.""" + _options = dict(config_entry.options) + _options.pop("template_type") + validated_config = IMAGE_CONFIG_SCHEMA(_options) + async_add_entities( + [StateImageEntity(hass, validated_config, config_entry.entry_id)] + ) + + class StateImageEntity(TemplateEntity, ImageEntity): """Representation of a template image.""" @@ -91,6 +123,10 @@ class StateImageEntity(TemplateEntity, ImageEntity): TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) ImageEntity.__init__(self, hass, config[CONF_VERIFY_SSL]) self._url_template = config[CONF_URL] + self._attr_device_info = async_device_info_to_link_from_device_id( + hass, + config.get(CONF_DEVICE_ID), + ) @property def entity_picture(self) -> str | None: diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index df281b77daa..6eb0fbd75ad 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -25,6 +25,18 @@ }, "title": "Template button" }, + "image": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "name": "[%key:common::config_flow::data::name%]", + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + }, + "title": "Template image" + }, "sensor": { "data": { "device_id": "[%key:common::config_flow::data::device%]", @@ -44,6 +56,7 @@ "menu_options": { "binary_sensor": "Template a binary sensor", "button": "Template a button", + "image": "Template a image", "sensor": "Template a sensor" }, "title": "Template helper" @@ -72,6 +85,17 @@ }, "title": "[%key:component::template::config::step::button::title%]" }, + "image": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + }, + "title": "[%key:component::template::config::step::image::title%]" + }, "sensor": { "data": { "device_id": "[%key:common::config_flow::data::device%]", diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 10f7d45637f..124fc119450 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -81,8 +81,19 @@ from tests.typing import WebSocketGenerator }, {}, ), + ( + "image", + {"url": "{{ states('sensor.one') }}"}, + "2024-07-09T00:00:00+00:00", + {"one": "http://www.test.com", "two": ""}, + {}, + {"verify_ssl": True}, + {"verify_ssl": True}, + {}, + ), ], ) +@pytest.mark.freeze_time("2024-07-09 00:00:00+00:00") async def test_config_flow( hass: HomeAssistant, template_type, @@ -181,6 +192,14 @@ async def test_config_flow( {}, {}, ), + ( + "image", + { + "url": "{{ states('sensor.one') }}", + }, + {"verify_ssl": True}, + {"verify_ssl": True}, + ), ], ) async def test_config_flow_device( @@ -330,8 +349,25 @@ def get_suggested(schema, key): ], }, ), + ( + "image", + { + "url": "{{ states('sensor.one') }}", + }, + { + "url": "{{ states('sensor.two') }}", + }, + ["2024-07-09T00:00:00+00:00", "2024-07-09T00:00:00+00:00"], + {"one": "http://www.test.com", "two": "http://www.test2.com"}, + {"verify_ssl": True}, + { + "url": "{{ states('sensor.two') }}", + "verify_ssl": True, + }, + ), ], ) +@pytest.mark.freeze_time("2024-07-09 00:00:00+00:00") async def test_options( hass: HomeAssistant, template_type, @@ -1050,6 +1086,15 @@ async def test_option_flow_sensor_preview_config_entry_removed( {}, {}, ), + ( + "image", + { + "url": "{{ states('sensor.one') }}", + "verify_ssl": True, + }, + {}, + {}, + ), ], ) async def test_options_flow_change_device( diff --git a/tests/components/template/test_image.py b/tests/components/template/test_image.py index bda9e2530ca..d4e98d7a3ca 100644 --- a/tests/components/template/test_image.py +++ b/tests/components/template/test_image.py @@ -8,6 +8,7 @@ import httpx from PIL import Image import pytest import respx +from syrupy.assertion import SnapshotAssertion from homeassistant import setup from homeassistant.components.input_text import ( @@ -15,12 +16,13 @@ from homeassistant.components.input_text import ( DOMAIN as INPUT_TEXT_DOMAIN, SERVICE_SET_VALUE as INPUT_TEXT_SERVICE_SET_VALUE, ) +from homeassistant.components.template import DOMAIN from homeassistant.const import ATTR_ENTITY_PICTURE, CONF_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt as dt_util -from tests.common import assert_setup_component +from tests.common import MockConfigEntry, assert_setup_component from tests.typing import ClientSessionGenerator _DEFAULT = object() @@ -74,6 +76,37 @@ async def _assert_state( assert body == expected_image +@pytest.mark.freeze_time("2024-07-09 00:00:00+00:00") +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test the config flow.""" + + respx.get("http://example.com").respond( + stream=imgbytes_jpg, content_type="image/jpeg" + ) + + template_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My template", + "template_type": "image", + "url": "http://example.com", + }, + 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("image.my_template") + assert state is not None + assert state.state == "2024-07-09T00:00:00+00:00" + + @respx.mock @pytest.mark.freeze_time("2023-04-01 00:00:00+00:00") async def test_platform_config( @@ -503,3 +536,46 @@ async def test_trigger_image_custom_entity_picture( imgbytes_jpg, expected_entity_picture="http://example2.com", ) + + +async def test_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test for device for image 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 + + respx.get("http://example.com").respond( + stream=imgbytes_jpg, content_type="image/jpeg" + ) + + template_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My template", + "template_type": "image", + "url": "http://example.com", + "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("image.my_template") + assert template_entity is not None + assert template_entity.device_id == device_entry.id diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index 58f75560878..95a864e1ec9 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -297,6 +297,16 @@ async def async_yaml_patch_helper(hass, filename): "state": "{{1 == 2}}", }, ), + ( + { + "template_type": "image", + "name": "My template", + "url": "http://example.com", + }, + { + "url": "http://example.com", + }, + ), ( { "template_type": "button",