diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index a0263587048..08f8431556c 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -12,6 +12,7 @@ from typing import Final, final from aiohttp import hdrs, web import async_timeout +import httpx from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.config_entries import ConfigEntry @@ -25,6 +26,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, IMAGE_TIMEOUT # noqa: F401 @@ -40,6 +42,8 @@ ENTITY_IMAGE_URL: Final = "/api/image_proxy/{0}?token={1}" TOKEN_CHANGE_INTERVAL: Final = timedelta(minutes=5) _RND: Final = SystemRandom() +GET_IMAGE_TIMEOUT: Final = 10 + @dataclass class ImageEntityDescription(EntityDescription): @@ -118,8 +122,9 @@ class ImageEntity(Entity): _attr_should_poll: bool = False # No need to poll image entities _attr_state: None = None # State is determined by last_updated - def __init__(self) -> None: + def __init__(self, hass: HomeAssistant, verify_ssl: bool = False) -> None: """Initialize an image entity.""" + self._client = get_async_client(hass, verify_ssl=verify_ssl) self.access_tokens: collections.deque = collections.deque([], 2) self.async_update_token() @@ -146,8 +151,48 @@ class ImageEntity(Entity): async def async_image(self) -> bytes | None: """Return bytes of image.""" + + async def _async_load_image_from_url(url: str) -> Image | None: + """Load an image by url.""" + try: + response = await self._client.get( + url, timeout=GET_IMAGE_TIMEOUT, follow_redirects=True + ) + response.raise_for_status() + return Image( + content=response.content, + content_type=response.headers["content-type"], + ) + except httpx.TimeoutException: + _LOGGER.error("%s: Timeout getting image from %s", self.entity_id, url) + return None + except (httpx.RequestError, httpx.HTTPStatusError) as err: + _LOGGER.error( + "%s: Error getting new image from %s: %s", + self.entity_id, + url, + err, + ) + return None + + if (url := await self.async_image_url()) is not None: + # Ignore an empty url + if url == "": + return None + if (image := await _async_load_image_from_url(url)) is None: + return None + self._attr_content_type = image.content_type + return image.content return await self.hass.async_add_executor_job(self.image) + def image_url(self) -> str | None: + """Return URL of image.""" + return None + + async def async_image_url(self) -> str | None: + """Return URL of image.""" + return self.image_url() + @property @final def state(self) -> str | None: diff --git a/homeassistant/components/kitchen_sink/image.py b/homeassistant/components/kitchen_sink/image.py index 7719b188c38..4fe20f08de9 100644 --- a/homeassistant/components/kitchen_sink/image.py +++ b/homeassistant/components/kitchen_sink/image.py @@ -21,6 +21,7 @@ async def async_setup_platform( async_add_entities( [ DemoImage( + hass, "kitchen_sink_image_001", "QR Code", "image/png", @@ -44,13 +45,14 @@ class DemoImage(ImageEntity): def __init__( self, + hass: HomeAssistant, unique_id: str, name: str, content_type: str, image: str, ) -> None: """Initialize the image entity.""" - super().__init__() + super().__init__(hass) self._attr_content_type = content_type self._attr_name = name self._attr_unique_id = unique_id diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index 80b0a6be1f6..4b6519f744b 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -97,7 +97,7 @@ class MqttImage(MqttEntity, ImageEntity): ) -> None: """Initialize the MQTT Image.""" self._client = get_async_client(hass) - ImageEntity.__init__(self) + ImageEntity.__init__(self, hass) MqttEntity.__init__(self, hass, config, config_entry, discovery_data) @staticmethod diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index 751c91c755b..c8db3bf941f 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -89,9 +89,9 @@ class TemplateImage(ImageEntity): _url: str | None = None _verify_ssl: bool - def __init__(self, verify_ssl: bool) -> None: + def __init__(self, hass: HomeAssistant, verify_ssl: bool) -> None: """Initialize the image.""" - super().__init__() + super().__init__(hass) self._verify_ssl = verify_ssl async def async_image(self) -> bytes | None: @@ -137,7 +137,7 @@ class StateImageEntity(TemplateEntity, TemplateImage): ) -> None: """Initialize the image.""" TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) - TemplateImage.__init__(self, config[CONF_VERIFY_SSL]) + TemplateImage.__init__(self, hass, config[CONF_VERIFY_SSL]) self._url_template = config[CONF_URL] @property @@ -179,7 +179,7 @@ class TriggerImageEntity(TriggerEntity, TemplateImage): ) -> None: """Initialize the entity.""" TriggerEntity.__init__(self, hass, coordinator, config) - TemplateImage.__init__(self, config[CONF_VERIFY_SSL]) + TemplateImage.__init__(self, hass, config[CONF_VERIFY_SSL]) @property def entity_picture(self) -> str | None: diff --git a/tests/components/image/conftest.py b/tests/components/image/conftest.py index 3dad2932928..38d386eeb6b 100644 --- a/tests/components/image/conftest.py +++ b/tests/components/image/conftest.py @@ -36,6 +36,20 @@ class MockImageEntity(image.ImageEntity): return b"Test" +class MockURLImageEntity(image.ImageEntity): + """Mock image entity.""" + + _attr_name = "Test" + + async def async_added_to_hass(self): + """Set the update time.""" + self._attr_image_last_updated = dt_util.utcnow() + + async def async_image_url(self) -> str: + """Return URL of image.""" + return "https://example.com/myimage.jpg" + + class MockImageNoStateEntity(image.ImageEntity): """Mock image entity.""" @@ -111,7 +125,7 @@ def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: @pytest.fixture(name="mock_image_config_entry") -async def mock_image_config_entry_fixture(hass: HomeAssistant, config_flow): +async def mock_image_config_entry_fixture(hass: HomeAssistant, config_flow: None): """Initialize a mock image config_entry.""" async def async_setup_entry_init( @@ -138,7 +152,9 @@ async def mock_image_config_entry_fixture(hass: HomeAssistant, config_flow): ) mock_platform( - hass, f"{TEST_DOMAIN}.{image.DOMAIN}", MockImageConfigEntry(MockImageEntity()) + hass, + f"{TEST_DOMAIN}.{image.DOMAIN}", + MockImageConfigEntry(MockImageEntity(hass)), ) config_entry = MockConfigEntry(domain=TEST_DOMAIN) @@ -153,7 +169,7 @@ async def mock_image_config_entry_fixture(hass: HomeAssistant, config_flow): async def mock_image_platform_fixture(hass: HomeAssistant): """Initialize a mock image platform.""" mock_integration(hass, MockModule(domain="test")) - mock_platform(hass, "test.image", MockImagePlatform([MockImageEntity()])) + mock_platform(hass, "test.image", MockImagePlatform([MockImageEntity(hass)])) assert await async_setup_component( hass, image.DOMAIN, {"image": {"platform": "test"}} ) diff --git a/tests/components/image/test_init.py b/tests/components/image/test_init.py index 5be9eefa0cc..3555026d45c 100644 --- a/tests/components/image/test_init.py +++ b/tests/components/image/test_init.py @@ -1,9 +1,12 @@ """The tests for the image component.""" from http import HTTPStatus -from unittest.mock import patch +import ssl +from unittest.mock import MagicMock, patch from aiohttp import hdrs +import httpx import pytest +import respx from homeassistant.components import image from homeassistant.core import HomeAssistant @@ -14,6 +17,7 @@ from .conftest import ( MockImageNoStateEntity, MockImagePlatform, MockImageSyncEntity, + MockURLImageEntity, ) from tests.common import MockModule, mock_integration, mock_platform @@ -56,7 +60,7 @@ async def test_state_attr( ) -> None: """Test image state with entity picture from attr.""" mock_integration(hass, MockModule(domain="test")) - entity = MockImageEntity() + entity = MockImageEntity(hass) entity._attr_entity_picture = "abcd" mock_platform(hass, "test.image", MockImagePlatform([entity])) assert await async_setup_component( @@ -79,7 +83,7 @@ async def test_no_state( ) -> None: """Test image state.""" mock_integration(hass, MockModule(domain="test")) - mock_platform(hass, "test.image", MockImagePlatform([MockImageNoStateEntity()])) + mock_platform(hass, "test.image", MockImagePlatform([MockImageNoStateEntity(hass)])) assert await async_setup_component( hass, image.DOMAIN, {"image": {"platform": "test"}} ) @@ -126,7 +130,7 @@ async def test_fetch_image_sync( ) -> None: """Test fetching an image with an authenticated client.""" mock_integration(hass, MockModule(domain="test")) - mock_platform(hass, "test.image", MockImagePlatform([MockImageSyncEntity()])) + mock_platform(hass, "test.image", MockImagePlatform([MockImageSyncEntity(hass)])) assert await async_setup_component( hass, image.DOMAIN, {"image": {"platform": "test"}} ) @@ -167,3 +171,57 @@ async def test_fetch_image_unauthenticated( resp = await client.get("/api/image_proxy/image.unknown") assert resp.status == HTTPStatus.NOT_FOUND + + +@respx.mock +async def test_fetch_image_url_success( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test fetching an image with an authenticated client.""" + respx.get("https://example.com/myimage.jpg").respond( + status_code=HTTPStatus.OK, content_type="image/png", content=b"Test" + ) + + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.image", MockImagePlatform([MockURLImageEntity(hass)])) + assert await async_setup_component( + hass, image.DOMAIN, {"image": {"platform": "test"}} + ) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.get("/api/image_proxy/image.test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == b"Test" + + +@respx.mock +@pytest.mark.parametrize( + "side_effect", + [ + httpx.RequestError("server offline", request=MagicMock()), + httpx.TimeoutException, + ssl.SSLError, + ], +) +async def test_fetch_image_url_exception( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + side_effect: Exception, +) -> None: + """Test fetching an image with an authenticated client.""" + respx.get("https://example.com/myimage.jpg").mock(side_effect=side_effect) + + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.image", MockImagePlatform([MockURLImageEntity(hass)])) + assert await async_setup_component( + hass, image.DOMAIN, {"image": {"platform": "test"}} + ) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.get("/api/image_proxy/image.test") + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR