Image entity media source (#104743)

* Image entity media source

* MJPEG streaming

* Update on change rather than fixed interval

* Only send boundary twice

* return when image has no data

* Write each frame twice

* Use friendly name when browsing

* Fix sending of double frame

* Initial image proxy test

* Improve proxy stream test

* Refactor

* Code review fixes
This commit is contained in:
On Freund 2024-02-26 20:04:25 +02:00 committed by GitHub
parent baf84b6fba
commit 979fe57f7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 246 additions and 3 deletions

View File

@ -15,7 +15,7 @@ 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
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.core import Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.config_validation import ( # noqa: F401 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 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 (
EventStateChangedData,
async_track_state_change_event,
async_track_time_interval,
)
from homeassistant.helpers.httpx_client import get_async_client 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 from .const import DOMAIN, IMAGE_TIMEOUT # noqa: F401
@ -49,6 +53,10 @@ _RND: Final = SystemRandom()
GET_IMAGE_TIMEOUT: Final = 10 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): class ImageEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes image entities.""" """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(ImageView(component))
hass.http.register_view(ImageStreamView(component))
await component.async_setup(config) await component.async_setup(config)
@ -295,3 +304,71 @@ class ImageView(HomeAssistantView):
raise web.HTTPInternalServerError() from ex raise web.HTTPInternalServerError() from ex
return web.Response(body=image.content, content_type=image.content_type) 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)

View File

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

View File

@ -1,4 +1,5 @@
"""The tests for the image component.""" """The tests for the image component."""
import datetime
from http import HTTPStatus from http import HTTPStatus
import ssl import ssl
from unittest.mock import MagicMock, patch 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") resp = await client.get("/api/image_proxy/image.test")
assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR 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

View File

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