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
|
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:
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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:
|
||||||
|
@ -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"}}
|
||||||
)
|
)
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user