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

View File

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

View File

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

View File

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

View File

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

View File

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