Add image url support (#95301)

* Add image url support

* Use hass as parameter

* Add verify ssl parameter and improve exception handling

* Improve error handling, ignore empty URL's

* Update existing image platforms

---------

Co-authored-by: Erik <erik@montnemery.com>
This commit is contained in:
Jan Bouwhuis 2023-06-27 08:36:12 +02:00 committed by GitHub
parent 363dab7ce4
commit 98cc45ec10
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 135 additions and 14 deletions

View File

@ -12,6 +12,7 @@ from typing import Final, final
from aiohttp import hdrs, web from aiohttp import hdrs, web
import async_timeout import async_timeout
import httpx
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.config_entries import ConfigEntry 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 import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_time_interval 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 homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, IMAGE_TIMEOUT # noqa: F401 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) TOKEN_CHANGE_INTERVAL: Final = timedelta(minutes=5)
_RND: Final = SystemRandom() _RND: Final = SystemRandom()
GET_IMAGE_TIMEOUT: Final = 10
@dataclass @dataclass
class ImageEntityDescription(EntityDescription): class ImageEntityDescription(EntityDescription):
@ -118,8 +122,9 @@ class ImageEntity(Entity):
_attr_should_poll: bool = False # No need to poll image entities _attr_should_poll: bool = False # No need to poll image entities
_attr_state: None = None # State is determined by last_updated _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.""" """Initialize an image entity."""
self._client = get_async_client(hass, verify_ssl=verify_ssl)
self.access_tokens: collections.deque = collections.deque([], 2) self.access_tokens: collections.deque = collections.deque([], 2)
self.async_update_token() self.async_update_token()
@ -146,8 +151,48 @@ class ImageEntity(Entity):
async def async_image(self) -> bytes | None: async def async_image(self) -> bytes | None:
"""Return bytes of image.""" """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) 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 @property
@final @final
def state(self) -> str | None: def state(self) -> str | None:

View File

@ -21,6 +21,7 @@ async def async_setup_platform(
async_add_entities( async_add_entities(
[ [
DemoImage( DemoImage(
hass,
"kitchen_sink_image_001", "kitchen_sink_image_001",
"QR Code", "QR Code",
"image/png", "image/png",
@ -44,13 +45,14 @@ class DemoImage(ImageEntity):
def __init__( def __init__(
self, self,
hass: HomeAssistant,
unique_id: str, unique_id: str,
name: str, name: str,
content_type: str, content_type: str,
image: str, image: str,
) -> None: ) -> None:
"""Initialize the image entity.""" """Initialize the image entity."""
super().__init__() super().__init__(hass)
self._attr_content_type = content_type self._attr_content_type = content_type
self._attr_name = name self._attr_name = name
self._attr_unique_id = unique_id self._attr_unique_id = unique_id

View File

@ -97,7 +97,7 @@ class MqttImage(MqttEntity, ImageEntity):
) -> None: ) -> None:
"""Initialize the MQTT Image.""" """Initialize the MQTT Image."""
self._client = get_async_client(hass) self._client = get_async_client(hass)
ImageEntity.__init__(self) ImageEntity.__init__(self, hass)
MqttEntity.__init__(self, hass, config, config_entry, discovery_data) MqttEntity.__init__(self, hass, config, config_entry, discovery_data)
@staticmethod @staticmethod

View File

@ -89,9 +89,9 @@ class TemplateImage(ImageEntity):
_url: str | None = None _url: str | None = None
_verify_ssl: bool _verify_ssl: bool
def __init__(self, verify_ssl: bool) -> None: def __init__(self, hass: HomeAssistant, verify_ssl: bool) -> None:
"""Initialize the image.""" """Initialize the image."""
super().__init__() super().__init__(hass)
self._verify_ssl = verify_ssl self._verify_ssl = verify_ssl
async def async_image(self) -> bytes | None: async def async_image(self) -> bytes | None:
@ -137,7 +137,7 @@ class StateImageEntity(TemplateEntity, TemplateImage):
) -> None: ) -> None:
"""Initialize the image.""" """Initialize the image."""
TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) 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] self._url_template = config[CONF_URL]
@property @property
@ -179,7 +179,7 @@ class TriggerImageEntity(TriggerEntity, TemplateImage):
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
TriggerEntity.__init__(self, hass, coordinator, config) TriggerEntity.__init__(self, hass, coordinator, config)
TemplateImage.__init__(self, config[CONF_VERIFY_SSL]) TemplateImage.__init__(self, hass, config[CONF_VERIFY_SSL])
@property @property
def entity_picture(self) -> str | None: def entity_picture(self) -> str | None:

View File

@ -36,6 +36,20 @@ class MockImageEntity(image.ImageEntity):
return b"Test" 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): class MockImageNoStateEntity(image.ImageEntity):
"""Mock image entity.""" """Mock image entity."""
@ -111,7 +125,7 @@ def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]:
@pytest.fixture(name="mock_image_config_entry") @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.""" """Initialize a mock image config_entry."""
async def async_setup_entry_init( async def async_setup_entry_init(
@ -138,7 +152,9 @@ async def mock_image_config_entry_fixture(hass: HomeAssistant, config_flow):
) )
mock_platform( 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) 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): async def mock_image_platform_fixture(hass: HomeAssistant):
"""Initialize a mock image platform.""" """Initialize a mock image platform."""
mock_integration(hass, MockModule(domain="test")) 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( assert await async_setup_component(
hass, image.DOMAIN, {"image": {"platform": "test"}} hass, image.DOMAIN, {"image": {"platform": "test"}}
) )

View File

@ -1,9 +1,12 @@
"""The tests for the image component.""" """The tests for the image component."""
from http import HTTPStatus from http import HTTPStatus
from unittest.mock import patch import ssl
from unittest.mock import MagicMock, patch
from aiohttp import hdrs from aiohttp import hdrs
import httpx
import pytest import pytest
import respx
from homeassistant.components import image from homeassistant.components import image
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -14,6 +17,7 @@ from .conftest import (
MockImageNoStateEntity, MockImageNoStateEntity,
MockImagePlatform, MockImagePlatform,
MockImageSyncEntity, MockImageSyncEntity,
MockURLImageEntity,
) )
from tests.common import MockModule, mock_integration, mock_platform from tests.common import MockModule, mock_integration, mock_platform
@ -56,7 +60,7 @@ async def test_state_attr(
) -> None: ) -> None:
"""Test image state with entity picture from attr.""" """Test image state with entity picture from attr."""
mock_integration(hass, MockModule(domain="test")) mock_integration(hass, MockModule(domain="test"))
entity = MockImageEntity() entity = MockImageEntity(hass)
entity._attr_entity_picture = "abcd" entity._attr_entity_picture = "abcd"
mock_platform(hass, "test.image", MockImagePlatform([entity])) mock_platform(hass, "test.image", MockImagePlatform([entity]))
assert await async_setup_component( assert await async_setup_component(
@ -79,7 +83,7 @@ async def test_no_state(
) -> None: ) -> None:
"""Test image state.""" """Test image state."""
mock_integration(hass, MockModule(domain="test")) 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( assert await async_setup_component(
hass, image.DOMAIN, {"image": {"platform": "test"}} hass, image.DOMAIN, {"image": {"platform": "test"}}
) )
@ -126,7 +130,7 @@ async def test_fetch_image_sync(
) -> None: ) -> None:
"""Test fetching an image with an authenticated client.""" """Test fetching an image with an authenticated client."""
mock_integration(hass, MockModule(domain="test")) 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( assert await async_setup_component(
hass, image.DOMAIN, {"image": {"platform": "test"}} 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") resp = await client.get("/api/image_proxy/image.unknown")
assert resp.status == HTTPStatus.NOT_FOUND 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