diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index 17ceac9c8db..164b7048da8 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -15,7 +15,7 @@ import httpx from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.config_validation import ( # noqa: F401 @@ -24,9 +24,13 @@ 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.event import ( + EventStateChangedData, + async_track_state_change_event, + async_track_time_interval, +) from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType +from homeassistant.helpers.typing import UNDEFINED, ConfigType, EventType, UndefinedType from .const import DOMAIN, IMAGE_TIMEOUT # noqa: F401 @@ -49,6 +53,10 @@ _RND: Final = SystemRandom() GET_IMAGE_TIMEOUT: Final = 10 +FRAME_BOUNDARY = "frame-boundary" +FRAME_SEPARATOR = bytes(f"\r\n--{FRAME_BOUNDARY}\r\n", "utf-8") +LAST_FRAME_MARKER = bytes(f"\r\n--{FRAME_BOUNDARY}--\r\n", "utf-8") + class ImageEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes image entities.""" @@ -92,6 +100,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) hass.http.register_view(ImageView(component)) + hass.http.register_view(ImageStreamView(component)) await component.async_setup(config) @@ -295,3 +304,71 @@ class ImageView(HomeAssistantView): raise web.HTTPInternalServerError() from ex return web.Response(body=image.content, content_type=image.content_type) + + +async def async_get_still_stream( + request: web.Request, + image_entity: ImageEntity, +) -> web.StreamResponse: + """Generate an HTTP multipart stream from the Image.""" + response = web.StreamResponse() + response.content_type = CONTENT_TYPE_MULTIPART.format(FRAME_BOUNDARY) + await response.prepare(request) + + async def _write_frame() -> bool: + img_bytes = await image_entity.async_image() + if img_bytes is None: + await response.write(LAST_FRAME_MARKER) + return False + frame = bytearray(FRAME_SEPARATOR) + header = bytes( + f"Content-Type: {image_entity.content_type}\r\n" + f"Content-Length: {len(img_bytes)}\r\n\r\n", + "utf-8", + ) + frame.extend(header) + frame.extend(img_bytes) + # Chrome shows the n-1 frame so send the frame twice + # https://issues.chromium.org/issues/41199053 + # https://issues.chromium.org/issues/40791855 + # While this results in additional bandwidth usage, + # given the low frequency of image updates, it is acceptable. + frame.extend(frame) + await response.write(frame) + # Drain to ensure that the latest frame is available to the client + await response.drain() + return True + + event = asyncio.Event() + + async def image_state_update(_event: EventType[EventStateChangedData]) -> None: + """Write image to stream.""" + event.set() + + hass: HomeAssistant = request.app["hass"] + remove = async_track_state_change_event( + hass, + image_entity.entity_id, + image_state_update, + ) + try: + while True: + if not await _write_frame(): + return response + await event.wait() + event.clear() + finally: + remove() + + +class ImageStreamView(ImageView): + """Image View to serve an multipart stream.""" + + url = "/api/image_proxy_stream/{entity_id}" + name = "api:image:stream" + + async def handle( + self, request: web.Request, image_entity: ImageEntity + ) -> web.StreamResponse: + """Serve image stream.""" + return await async_get_still_stream(request, image_entity) diff --git a/homeassistant/components/image/media_source.py b/homeassistant/components/image/media_source.py new file mode 100644 index 00000000000..39f00b587c0 --- /dev/null +++ b/homeassistant/components/image/media_source.py @@ -0,0 +1,84 @@ +"""Expose iamges as media sources.""" +from __future__ import annotations + +from typing import cast + +from homeassistant.components.media_player import BrowseError, MediaClass +from homeassistant.components.media_source.error import Unresolvable +from homeassistant.components.media_source.models import ( + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, +) +from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.entity_component import EntityComponent + +from . import ImageEntity +from .const import DOMAIN + + +async def async_get_media_source(hass: HomeAssistant) -> ImageMediaSource: + """Set up image media source.""" + return ImageMediaSource(hass) + + +class ImageMediaSource(MediaSource): + """Provide images as media sources.""" + + name: str = "Image" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize ImageMediaSource.""" + super().__init__(DOMAIN) + self.hass = hass + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Resolve media to a url.""" + component: EntityComponent[ImageEntity] = self.hass.data[DOMAIN] + image = component.get_entity(item.identifier) + + if not image: + raise Unresolvable(f"Could not resolve media item: {item.identifier}") + + return PlayMedia( + f"/api/image_proxy_stream/{image.entity_id}", image.content_type + ) + + async def async_browse_media( + self, + item: MediaSourceItem, + ) -> BrowseMediaSource: + """Return media.""" + if item.identifier: + raise BrowseError("Unknown item") + + component: EntityComponent[ImageEntity] = self.hass.data[DOMAIN] + children = [ + BrowseMediaSource( + domain=DOMAIN, + identifier=image.entity_id, + media_class=MediaClass.VIDEO, + media_content_type=image.content_type, + title=cast(State, self.hass.states.get(image.entity_id)).attributes.get( + ATTR_FRIENDLY_NAME, image.name + ), + thumbnail=f"/api/image_proxy/{image.entity_id}", + can_play=True, + can_expand=False, + ) + for image in component.entities + ] + + return BrowseMediaSource( + domain=DOMAIN, + identifier=None, + media_class=MediaClass.APP, + media_content_type="", + title="Image", + can_play=False, + can_expand=True, + children_media_class=MediaClass.IMAGE, + children=children, + ) diff --git a/tests/components/image/test_init.py b/tests/components/image/test_init.py index 0950d6f42e5..e68a58d7298 100644 --- a/tests/components/image/test_init.py +++ b/tests/components/image/test_init.py @@ -1,4 +1,5 @@ """The tests for the image component.""" +import datetime from http import HTTPStatus import ssl from unittest.mock import MagicMock, patch @@ -287,3 +288,39 @@ async def test_fetch_image_url_wrong_content_type( resp = await client.get("/api/image_proxy/image.test") assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + + +async def test_image_stream( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test image stream.""" + + mock_integration(hass, MockModule(domain="test")) + mock_image = MockURLImageEntity(hass) + mock_platform(hass, "test.image", MockImagePlatform([mock_image])) + assert await async_setup_component( + hass, image.DOMAIN, {"image": {"platform": "test"}} + ) + await hass.async_block_till_done() + + client = await hass_client() + + with patch.object(mock_image, "async_image", return_value=b""): + resp = await client.get("/api/image_proxy_stream/image.test") + assert not resp.closed + assert resp.status == HTTPStatus.OK + + mock_image.image_last_updated = datetime.datetime.now() + mock_image.async_write_ha_state() + # Two blocks to ensure the frame is written + await hass.async_block_till_done() + await hass.async_block_till_done() + + with patch.object(mock_image, "async_image", return_value=None): + mock_image.image_last_updated = datetime.datetime.now() + mock_image.async_write_ha_state() + # Two blocks to ensure the frame is written + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert resp.closed diff --git a/tests/components/image/test_media_source.py b/tests/components/image/test_media_source.py new file mode 100644 index 00000000000..2037641a1a3 --- /dev/null +++ b/tests/components/image/test_media_source.py @@ -0,0 +1,45 @@ +"""Test image media source.""" + +import pytest + +from homeassistant.components import media_source +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +@pytest.fixture(autouse=True) +async def setup_media_source(hass): + """Set up media source.""" + assert await async_setup_component(hass, "media_source", {}) + + +async def test_browsing(hass: HomeAssistant, mock_image_platform) -> None: + """Test browsing image media source.""" + item = await media_source.async_browse_media(hass, "media-source://image") + assert item is not None + assert item.title == "Image" + assert len(item.children) == 1 + assert item.children[0].media_content_type == "image/jpeg" + + +async def test_resolving(hass: HomeAssistant, mock_image_platform) -> None: + """Test resolving.""" + item = await media_source.async_resolve_media( + hass, "media-source://image/image.test", None + ) + assert item is not None + assert item.url == "/api/image_proxy_stream/image.test" + assert item.mime_type == "image/jpeg" + + +async def test_resolving_non_existing_camera( + hass: HomeAssistant, mock_image_platform +) -> None: + """Test resolving.""" + with pytest.raises( + media_source.Unresolvable, + match="Could not resolve media item: image.non_existing", + ): + await media_source.async_resolve_media( + hass, "media-source://image/image.non_existing", None + )