From 4d40d958488ffe40148ab6fd7a702c1da5b2fca6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Aug 2021 20:31:11 -0500 Subject: [PATCH] Add support for width and height to ffmpeg based camera snapshots (#53837) --- homeassistant/components/canary/camera.py | 18 +++---- homeassistant/components/ezviz/camera.py | 12 ++--- homeassistant/components/ffmpeg/__init__.py | 12 +++++ homeassistant/components/onvif/camera.py | 19 +++---- homeassistant/components/ring/camera.py | 21 +++----- homeassistant/components/xiaomi/camera.py | 14 ++--- homeassistant/components/yi/camera.py | 14 ++--- tests/components/ffmpeg/test_init.py | 59 ++++++++++++++++++++- 8 files changed, 109 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index 7a2d22c2406..a475a27f942 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -1,7 +1,6 @@ """Support for Canary camera.""" from __future__ import annotations -import asyncio from datetime import timedelta from typing import Final @@ -9,9 +8,9 @@ from aiohttp.web import Request, StreamResponse from canary.api import Device, Location from canary.live_stream_api import LiveStreamSession from haffmpeg.camera import CameraMjpeg -from haffmpeg.tools import IMAGE_JPEG, ImageFrame import voluptuous as vol +from homeassistant.components import ffmpeg from homeassistant.components.camera import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, Camera, @@ -131,16 +130,13 @@ class CanaryCamera(CoordinatorEntity, Camera): live_stream_url = await self.hass.async_add_executor_job( getattr, self._live_stream_session, "live_stream_url" ) - - ffmpeg = ImageFrame(self._ffmpeg.binary) - image: bytes | None = await asyncio.shield( - ffmpeg.get_image( - live_stream_url, - output_format=IMAGE_JPEG, - extra_cmd=self._ffmpeg_arguments, - ) + return await ffmpeg.async_get_image( + self.hass, + live_stream_url, + extra_cmd=self._ffmpeg_arguments, + width=width, + height=height, ) - return image async def handle_async_mjpeg_stream( self, request: Request diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 4e5fdb90c79..44a90e2928f 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -1,13 +1,12 @@ """Support ezviz camera devices.""" from __future__ import annotations -import asyncio import logging -from haffmpeg.tools import IMAGE_JPEG, ImageFrame from pyezviz.exceptions import HTTPError, InvalidHost, PyEzvizError import voluptuous as vol +from homeassistant.components import ffmpeg from homeassistant.components.camera import PLATFORM_SCHEMA, SUPPORT_STREAM, Camera from homeassistant.components.ffmpeg import DATA_FFMPEG from homeassistant.config_entries import ( @@ -329,12 +328,11 @@ class EzvizCamera(CoordinatorEntity, Camera): self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a frame from the camera stream.""" - ffmpeg = ImageFrame(self._ffmpeg.binary) - - image = await asyncio.shield( - ffmpeg.get_image(self._rtsp_stream, output_format=IMAGE_JPEG) + if self._rtsp_stream is None: + return None + return await ffmpeg.async_get_image( + self.hass, self._rtsp_stream, width=width, height=height ) - return image @property def device_info(self) -> DeviceInfo: diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py index 52e034c6265..74c826f47d6 100644 --- a/homeassistant/components/ffmpeg/__init__.py +++ b/homeassistant/components/ffmpeg/__init__.py @@ -20,6 +20,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity import Entity +from homeassistant.loader import bind_hass DOMAIN = "ffmpeg" @@ -89,15 +90,26 @@ async def async_setup(hass, config): return True +@bind_hass async def async_get_image( hass: HomeAssistant, input_source: str, output_format: str = IMAGE_JPEG, extra_cmd: str | None = None, + width: int | None = None, + height: int | None = None, ) -> bytes | None: """Get an image from a frame of an RTSP stream.""" manager = hass.data[DATA_FFMPEG] ffmpeg = ImageFrame(manager.binary) + + if width and height and (extra_cmd is None or "-s" not in extra_cmd): + size_cmd = f"-s {width}x{height}" + if extra_cmd is None: + extra_cmd = size_cmd + else: + extra_cmd += " " + size_cmd + image = await asyncio.shield( ffmpeg.get_image(input_source, output_format=output_format, extra_cmd=extra_cmd) ) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 4d80231df23..bb7cffa86f9 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -1,14 +1,12 @@ """Support for ONVIF Cameras with FFmpeg as decoder.""" from __future__ import annotations -import asyncio - from haffmpeg.camera import CameraMjpeg -from haffmpeg.tools import IMAGE_JPEG, ImageFrame from onvif.exceptions import ONVIFError import voluptuous as vol from yarl import URL +from homeassistant.components import ffmpeg from homeassistant.components.camera import SUPPORT_STREAM, Camera from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS, DATA_FFMPEG from homeassistant.const import HTTP_BASIC_AUTHENTICATION @@ -141,15 +139,12 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): ) if image is None: - ffmpeg = ImageFrame(self.hass.data[DATA_FFMPEG].binary) - image = await asyncio.shield( - ffmpeg.get_image( - self._stream_uri, - output_format=IMAGE_JPEG, - extra_cmd=self.device.config_entry.options.get( - CONF_EXTRA_ARGUMENTS - ), - ) + return await ffmpeg.async_get_image( + self.hass, + self._stream_uri, + extra_cmd=self.device.config_entry.options.get(CONF_EXTRA_ARGUMENTS), + width=width, + height=height, ) return image diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 77317d62ab3..509877ae5ff 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -1,15 +1,14 @@ """This component provides support to the Ring Door Bell camera.""" from __future__ import annotations -import asyncio from datetime import timedelta from itertools import chain import logging from haffmpeg.camera import CameraMjpeg -from haffmpeg.tools import IMAGE_JPEG, ImageFrame import requests +from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera from homeassistant.components.ffmpeg import DATA_FFMPEG from homeassistant.const import ATTR_ATTRIBUTION @@ -44,12 +43,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class RingCam(RingEntityMixin, Camera): """An implementation of a Ring Door Bell camera.""" - def __init__(self, config_entry_id, ffmpeg, device): + def __init__(self, config_entry_id, ffmpeg_manager, device): """Initialize a Ring Door Bell camera.""" super().__init__(config_entry_id, device) self._name = self._device.name - self._ffmpeg = ffmpeg + self._ffmpeg_manager = ffmpeg_manager self._last_event = None self._last_video_id = None self._video_url = None @@ -107,25 +106,19 @@ class RingCam(RingEntityMixin, Camera): self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" - ffmpeg = ImageFrame(self._ffmpeg.binary) - if self._video_url is None: return - image = await asyncio.shield( - ffmpeg.get_image( - self._video_url, - output_format=IMAGE_JPEG, - ) + return await ffmpeg.async_get_image( + self.hass, self._video_url, width=width, height=height ) - return image async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" if self._video_url is None: return - stream = CameraMjpeg(self._ffmpeg.binary) + stream = CameraMjpeg(self._ffmpeg_manager.binary) await stream.open_camera(self._video_url) try: @@ -134,7 +127,7 @@ class RingCam(RingEntityMixin, Camera): self.hass, request, stream_reader, - self._ffmpeg.ffmpeg_stream_content_type, + self._ffmpeg_manager.ffmpeg_stream_content_type, ) finally: await stream.close() diff --git a/homeassistant/components/xiaomi/camera.py b/homeassistant/components/xiaomi/camera.py index c89b23e9081..016fe7dd2ba 100644 --- a/homeassistant/components/xiaomi/camera.py +++ b/homeassistant/components/xiaomi/camera.py @@ -1,14 +1,13 @@ """This component provides support for Xiaomi Cameras.""" from __future__ import annotations -import asyncio from ftplib import FTP, error_perm import logging from haffmpeg.camera import CameraMjpeg -from haffmpeg.tools import IMAGE_JPEG, ImageFrame import voluptuous as vol +from homeassistant.components import ffmpeg from homeassistant.components.camera import PLATFORM_SCHEMA, Camera from homeassistant.components.ffmpeg import DATA_FFMPEG from homeassistant.const import ( @@ -153,11 +152,12 @@ class XiaomiCamera(Camera): url = await self.hass.async_add_executor_job(self.get_latest_video_url, host) if url != self._last_url: - ffmpeg = ImageFrame(self._manager.binary) - self._last_image = await asyncio.shield( - ffmpeg.get_image( - url, output_format=IMAGE_JPEG, extra_cmd=self._extra_arguments - ) + self._last_image = await ffmpeg.async_get_image( + self.hass, + url, + extra_cmd=self._extra_arguments, + width=width, + height=height, ) self._last_url = url diff --git a/homeassistant/components/yi/camera.py b/homeassistant/components/yi/camera.py index 6f898bb9a9b..91dfaab38bf 100644 --- a/homeassistant/components/yi/camera.py +++ b/homeassistant/components/yi/camera.py @@ -1,14 +1,13 @@ """Support for Xiaomi Cameras (HiSilicon Hi3518e V200).""" from __future__ import annotations -import asyncio import logging from aioftp import Client, StatusCodeError from haffmpeg.camera import CameraMjpeg -from haffmpeg.tools import IMAGE_JPEG, ImageFrame import voluptuous as vol +from homeassistant.components import ffmpeg from homeassistant.components.camera import PLATFORM_SCHEMA, Camera from homeassistant.components.ffmpeg import DATA_FFMPEG from homeassistant.const import ( @@ -127,11 +126,12 @@ class YiCamera(Camera): """Return a still image response from the camera.""" url = await self._get_latest_video_url() if url and url != self._last_url: - ffmpeg = ImageFrame(self._manager.binary) - self._last_image = await asyncio.shield( - ffmpeg.get_image( - url, output_format=IMAGE_JPEG, extra_cmd=self._extra_arguments - ), + self._last_image = await ffmpeg.async_get_image( + self.hass, + url, + extra_cmd=self._extra_arguments, + width=width, + height=height, ) self._last_url = url diff --git a/tests/components/ffmpeg/test_init.py b/tests/components/ffmpeg/test_init.py index 3c6a2fbb92d..e1730ffdabb 100644 --- a/tests/components/ffmpeg/test_init.py +++ b/tests/components/ffmpeg/test_init.py @@ -1,7 +1,7 @@ """The tests for Home Assistant ffmpeg.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, Mock, call, patch -import homeassistant.components.ffmpeg as ffmpeg +from homeassistant.components import ffmpeg from homeassistant.components.ffmpeg import ( DOMAIN, SERVICE_RESTART, @@ -181,3 +181,58 @@ async def test_setup_component_test_service_start_with_entity(hass): assert ffmpeg_dev.called_start assert ffmpeg_dev.called_entities == ["test.ffmpeg_device"] + + +async def test_async_get_image_with_width_height(hass): + """Test fetching an image with a specific width and height.""" + with assert_setup_component(1): + await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + + get_image_mock = AsyncMock() + with patch( + "homeassistant.components.ffmpeg.ImageFrame", + return_value=Mock(get_image=get_image_mock), + ): + await ffmpeg.async_get_image(hass, "rtsp://fake", width=640, height=480) + + assert get_image_mock.call_args_list == [ + call("rtsp://fake", output_format="mjpeg", extra_cmd="-s 640x480") + ] + + +async def test_async_get_image_with_extra_cmd_overlapping_width_height(hass): + """Test fetching an image with and extra_cmd with width and height and a specific width and height.""" + with assert_setup_component(1): + await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + + get_image_mock = AsyncMock() + with patch( + "homeassistant.components.ffmpeg.ImageFrame", + return_value=Mock(get_image=get_image_mock), + ): + await ffmpeg.async_get_image( + hass, "rtsp://fake", extra_cmd="-s 1024x768", width=640, height=480 + ) + + assert get_image_mock.call_args_list == [ + call("rtsp://fake", output_format="mjpeg", extra_cmd="-s 1024x768") + ] + + +async def test_async_get_image_with_extra_cmd_width_height(hass): + """Test fetching an image with and extra_cmd and a specific width and height.""" + with assert_setup_component(1): + await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + + get_image_mock = AsyncMock() + with patch( + "homeassistant.components.ffmpeg.ImageFrame", + return_value=Mock(get_image=get_image_mock), + ): + await ffmpeg.async_get_image( + hass, "rtsp://fake", extra_cmd="-vf any", width=640, height=480 + ) + + assert get_image_mock.call_args_list == [ + call("rtsp://fake", output_format="mjpeg", extra_cmd="-vf any -s 640x480") + ]