From 51b24e009aa32b42e33f0c17da5450c14f8da5a4 Mon Sep 17 00:00:00 2001 From: Justin Wong <46082645+uvjustin@users.noreply.github.com> Date: Fri, 24 Dec 2021 04:48:28 +0000 Subject: [PATCH 1/5] Allow generic config with no CONF_STILL_IMAGE_URL --- homeassistant/components/generic/camera.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index b6e08ea8582c02..1e035331fab3f0 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -45,8 +45,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Required(CONF_STILL_IMAGE_URL): cv.template, - vol.Optional(CONF_STREAM_SOURCE): cv.template, + vol.Required(vol.Any(CONF_STILL_IMAGE_URL, CONF_STREAM_SOURCE)): cv.template, + vol.Optional(vol.Any(CONF_STILL_IMAGE_URL, CONF_STREAM_SOURCE)): cv.template, vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION): vol.In( [HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION] ), @@ -81,9 +81,10 @@ def __init__(self, hass, device_info): self.hass = hass self._authentication = device_info.get(CONF_AUTHENTICATION) self._name = device_info.get(CONF_NAME) - self._still_image_url = device_info[CONF_STILL_IMAGE_URL] + self._still_image_url = device_info.get(CONF_STILL_IMAGE_URL) + if self._still_image_url: + self._still_image_url.hass = hass self._stream_source = device_info.get(CONF_STREAM_SOURCE) - self._still_image_url.hass = hass if self._stream_source is not None: self._stream_source.hass = hass self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE] From 06d0410ca97a1fa3ae8c1d2fb35c88eaf87817bf Mon Sep 17 00:00:00 2001 From: Justin Wong <46082645+uvjustin@users.noreply.github.com> Date: Fri, 24 Dec 2021 05:02:08 +0000 Subject: [PATCH 2/5] Use Stream.async_get_image when no still image url --- homeassistant/components/generic/camera.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 1e035331fab3f0..97fc7ce59842a1 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -133,6 +133,11 @@ async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" + if not self._still_image_url: + if self.stream: + return await self.stream.async_get_image(width, height) + await self.async_create_stream() + return None try: url = self._still_image_url.async_render(parse_result=False) except TemplateError as err: From deb3c6154a55187770f6ac92718013e5a1af7447 Mon Sep 17 00:00:00 2001 From: Justin Wong <46082645+uvjustin@users.noreply.github.com> Date: Fri, 24 Dec 2021 05:08:43 +0000 Subject: [PATCH 3/5] Remove GenericCamera.camera_image --- homeassistant/components/generic/camera.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 97fc7ce59842a1..aace0155e250eb 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -1,7 +1,6 @@ """Support for IP Cameras.""" from __future__ import annotations -import asyncio import logging import httpx @@ -121,14 +120,6 @@ def frame_interval(self): """Return the interval between frames of the mjpeg stream.""" return self._frame_interval - def camera_image( - self, width: int | None = None, height: int | None = None - ) -> bytes | None: - """Return bytes of camera image.""" - return asyncio.run_coroutine_threadsafe( - self.async_camera_image(), self.hass.loop - ).result() - async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: From e6f7cd2a5cffe4db93e1389210359b54e81d2864 Mon Sep 17 00:00:00 2001 From: Justin Wong <46082645+uvjustin@users.noreply.github.com> Date: Fri, 24 Dec 2021 05:13:27 +0000 Subject: [PATCH 4/5] Add test --- tests/components/generic/test_camera.py | 39 ++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index c52b4bf6e8fdde..addcf1347bd604 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -14,7 +14,7 @@ from homeassistant.const import SERVICE_RELOAD from homeassistant.setup import async_setup_component -from tests.common import get_fixture_path +from tests.common import AsyncMock, Mock, get_fixture_path @respx.mock @@ -459,3 +459,40 @@ async def test_timeout_cancelled(hass, hass_client): assert respx.calls.call_count == total_calls assert resp.status == HTTPStatus.OK assert await resp.text() == "hello world" + + +async def test_no_still_image_url(hass, hass_client): + """Test that the stream source is setup with different config options.""" + assert await async_setup_component( + hass, + "camera", + { + "camera": { + "name": "config_test", + "platform": "generic", + "stream_source": "rtsp://example.com:554/rtsp/", + }, + }, + ) + await hass.async_block_till_done() + + client = await hass_client() + + with patch("homeassistant.components.camera.create_stream") as mock_create_stream: + + mock_stream = Mock() + mock_create_stream.return_value = mock_stream + mock_stream.async_get_image = AsyncMock() + mock_stream.async_get_image.return_value = b"stream_keyframe_image" + + # first request should fail but start the stream + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + await hass.async_block_till_done() + mock_create_stream.assert_called_once() + + # second request should succeed + resp = await client.get("/api/camera_proxy/camera.config_test") + mock_stream.async_get_image.assert_called_once() + assert resp.status == HTTPStatus.OK + assert await resp.read() == b"stream_keyframe_image" From 3c9a4afcc3d9e441e82f44d3732567ec973e4485 Mon Sep 17 00:00:00 2001 From: Justin Wong <46082645+uvjustin@users.noreply.github.com> Date: Sat, 25 Dec 2021 17:02:13 +0000 Subject: [PATCH 5/5] Add HLS output and start stream in Stream.async_get_image --- homeassistant/components/generic/camera.py | 3 ++- homeassistant/components/stream/__init__.py | 2 ++ tests/components/generic/test_camera.py | 22 ++++++++++++++------- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index aace0155e250eb..23fdd4191a3636 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -125,9 +125,10 @@ async def async_camera_image( ) -> bytes | None: """Return a still image response from the camera.""" if not self._still_image_url: + if not self.stream: + await self.async_create_stream() if self.stream: return await self.stream.async_get_image(width, height) - await self.async_create_stream() return None try: url = self._still_image_url.async_render(parse_result=False) diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 1a4ce3d92e8859..7019dbe60b2693 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -437,6 +437,8 @@ async def async_get_image( hass.add_executor_job underneath the hood. """ + self.add_provider(HLS_PROVIDER) + self.start() return await self._keyframe_converter.async_get_image( width=width, height=height ) diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index addcf1347bd604..e9b8d886bc3499 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -462,7 +462,7 @@ async def test_timeout_cancelled(hass, hass_client): async def test_no_still_image_url(hass, hass_client): - """Test that the stream source is setup with different config options.""" + """Test that the component can grab images from stream with no still_image_url.""" assert await async_setup_component( hass, "camera", @@ -478,21 +478,29 @@ async def test_no_still_image_url(hass, hass_client): client = await hass_client() + with patch( + "homeassistant.components.generic.camera.GenericCamera.stream_source", + return_value=None, + ) as mock_stream_source: + + # First test when there is no stream_source should fail + resp = await client.get("/api/camera_proxy/camera.config_test") + await hass.async_block_till_done() + mock_stream_source.assert_called_once() + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + with patch("homeassistant.components.camera.create_stream") as mock_create_stream: + # Now test when creating the stream succeeds mock_stream = Mock() - mock_create_stream.return_value = mock_stream mock_stream.async_get_image = AsyncMock() mock_stream.async_get_image.return_value = b"stream_keyframe_image" + mock_create_stream.return_value = mock_stream - # first request should fail but start the stream + # should start the stream and get the image resp = await client.get("/api/camera_proxy/camera.config_test") - assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR await hass.async_block_till_done() mock_create_stream.assert_called_once() - - # second request should succeed - resp = await client.get("/api/camera_proxy/camera.config_test") mock_stream.async_get_image.assert_called_once() assert resp.status == HTTPStatus.OK assert await resp.read() == b"stream_keyframe_image"