mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
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:
parent
363dab7ce4
commit
98cc45ec10
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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"}}
|
||||
)
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user