From e99576c0947e710fa40967458c3c01ed1ba20872 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Aug 2021 19:33:06 -0500 Subject: [PATCH] Pass width and height when requesting camera snapshot (#53835) --- homeassistant/components/abode/camera.py | 6 +- homeassistant/components/amcrest/camera.py | 6 +- homeassistant/components/arlo/camera.py | 6 +- homeassistant/components/august/camera.py | 5 +- homeassistant/components/blink/camera.py | 6 +- homeassistant/components/bloomsky/camera.py | 6 +- homeassistant/components/buienradar/camera.py | 4 +- homeassistant/components/camera/__init__.py | 108 +++++++++++++++--- .../{homekit => camera}/img_util.py | 34 ++++-- homeassistant/components/camera/manifest.json | 1 + homeassistant/components/canary/camera.py | 4 +- homeassistant/components/demo/camera.py | 6 +- homeassistant/components/doorbird/camera.py | 6 +- .../components/environment_canada/camera.py | 6 +- homeassistant/components/esphome/camera.py | 4 +- homeassistant/components/ezviz/camera.py | 4 +- homeassistant/components/familyhub/camera.py | 6 +- homeassistant/components/ffmpeg/camera.py | 5 +- homeassistant/components/foscam/camera.py | 6 +- homeassistant/components/generic/camera.py | 10 +- .../components/homekit/manifest.json | 3 +- .../components/homekit/type_cameras.py | 10 +- .../components/homekit_controller/camera.py | 10 +- homeassistant/components/hyperion/camera.py | 4 +- homeassistant/components/local_file/camera.py | 7 +- .../components/logi_circle/camera.py | 6 +- homeassistant/components/mjpeg/camera.py | 16 ++- homeassistant/components/mqtt/camera.py | 6 +- homeassistant/components/neato/camera.py | 4 +- homeassistant/components/nest/camera_sdm.py | 4 +- .../components/nest/legacy/camera.py | 6 +- homeassistant/components/netatmo/camera.py | 8 +- homeassistant/components/onvif/camera.py | 6 +- homeassistant/components/proxy/camera.py | 16 ++- homeassistant/components/push/camera.py | 6 +- homeassistant/components/qvr_pro/camera.py | 5 +- homeassistant/components/ring/camera.py | 6 +- homeassistant/components/rpi_camera/camera.py | 6 +- homeassistant/components/skybell/camera.py | 6 +- .../components/synology_dsm/camera.py | 4 +- homeassistant/components/uvc/camera.py | 6 +- homeassistant/components/verisure/camera.py | 4 +- homeassistant/components/vivotek/camera.py | 6 +- homeassistant/components/xeoma/camera.py | 6 +- homeassistant/components/xiaomi/camera.py | 6 +- homeassistant/components/yi/camera.py | 6 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/camera/common.py | 17 +++ .../{homekit => camera}/test_img_util.py | 30 ++++- tests/components/camera/test_init.py | 47 ++++++++ tests/components/homekit/common.py | 17 --- tests/components/homekit/test_type_cameras.py | 4 +- 53 files changed, 418 insertions(+), 113 deletions(-) rename homeassistant/components/{homekit => camera}/img_util.py (72%) rename tests/components/{homekit => camera}/test_img_util.py (67%) delete mode 100644 tests/components/homekit/common.py diff --git a/homeassistant/components/abode/camera.py b/homeassistant/components/abode/camera.py index 99d4fd433a7..987e32f9911 100644 --- a/homeassistant/components/abode/camera.py +++ b/homeassistant/components/abode/camera.py @@ -1,4 +1,6 @@ """Support for Abode Security System cameras.""" +from __future__ import annotations + from datetime import timedelta import abodepy.helpers.constants as CONST @@ -73,7 +75,9 @@ class AbodeCamera(AbodeDevice, Camera): else: self._response = None - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Get a camera image.""" self.refresh_image() diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 5c7f8acf94a..1478c658d18 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -1,4 +1,6 @@ """Support for Amcrest IP cameras.""" +from __future__ import annotations + import asyncio from datetime import timedelta from functools import partial @@ -181,7 +183,9 @@ class AmcrestCam(Camera): finally: self._snapshot_task = None - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" _LOGGER.debug("Take snapshot from %s", self._name) try: diff --git a/homeassistant/components/arlo/camera.py b/homeassistant/components/arlo/camera.py index 87c6216e56d..6b14f0cee0c 100644 --- a/homeassistant/components/arlo/camera.py +++ b/homeassistant/components/arlo/camera.py @@ -1,4 +1,6 @@ """Support for Netgear Arlo IP cameras.""" +from __future__ import annotations + import logging from haffmpeg.camera import CameraMjpeg @@ -62,7 +64,9 @@ class ArloCam(Camera): self._last_refresh = None self.attrs = {} - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" return self._camera.last_image_from_cache diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index 6bb47a06eee..6f9ecf1b182 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -1,4 +1,5 @@ """Support for August doorbell camera.""" +from __future__ import annotations from yalexs.activity import ActivityType from yalexs.util import update_doorbell_image_from_activity @@ -68,7 +69,9 @@ class AugustCamera(AugustEntityMixin, Camera): if doorbell_activity is not None: update_doorbell_image_from_activity(self._detail, doorbell_activity) - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" self._update_from_data() diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index e2216dc8785..8b4f1ba4eec 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -1,4 +1,6 @@ """Support for Blink system camera.""" +from __future__ import annotations + import logging from homeassistant.components.camera import Camera @@ -65,6 +67,8 @@ class BlinkCamera(Camera): self._camera.snap_picture() self.data.refresh() - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" return self._camera.image_from_cache.content diff --git a/homeassistant/components/bloomsky/camera.py b/homeassistant/components/bloomsky/camera.py index 570842b9c66..a7255a74d4c 100644 --- a/homeassistant/components/bloomsky/camera.py +++ b/homeassistant/components/bloomsky/camera.py @@ -1,4 +1,6 @@ """Support for a camera of a BloomSky weather station.""" +from __future__ import annotations + import logging import requests @@ -37,7 +39,9 @@ class BloomSkyCamera(Camera): self._logger = logging.getLogger(__name__) self._attr_unique_id = self._id - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Update the camera's image if it has changed.""" try: self._url = self._bloomsky.devices[self._id]["Data"]["ImageURL"] diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py index 34f1f173319..91e4bcffb17 100644 --- a/homeassistant/components/buienradar/camera.py +++ b/homeassistant/components/buienradar/camera.py @@ -143,7 +143,9 @@ class BuienradarCam(Camera): _LOGGER.error("Failed to fetch image, %s", type(err)) return False - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """ Return a still image response from the camera. diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index d1f354cc78e..c6cada2e3c9 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -8,7 +8,9 @@ from collections.abc import Awaitable, Mapping from contextlib import suppress from dataclasses import dataclass from datetime import datetime, timedelta +from functools import partial import hashlib +import inspect import logging import os from random import SystemRandom @@ -62,6 +64,7 @@ from .const import ( DOMAIN, SERVICE_RECORD, ) +from .img_util import scale_jpeg_camera_image from .prefs import CameraPreferences # mypy: allow-untyped-calls @@ -138,23 +141,72 @@ async def async_request_stream(hass: HomeAssistant, entity_id: str, fmt: str) -> return await _async_stream_endpoint_url(hass, camera, fmt) -@bind_hass -async def async_get_image( - hass: HomeAssistant, entity_id: str, timeout: int = 10 +async def _async_get_image( + camera: Camera, + timeout: int = 10, + width: int | None = None, + height: int | None = None, ) -> Image: - """Fetch an image from a camera entity.""" - camera = _get_camera_from_entity_id(hass, entity_id) + """Fetch a snapshot image from a camera. + If width and height are passed, an attempt to scale + the image will be made on a best effort basis. + Not all cameras can scale images or return jpegs + that we can scale, however the majority of cases + are handled. + """ with suppress(asyncio.CancelledError, asyncio.TimeoutError): async with async_timeout.timeout(timeout): - image = await camera.async_camera_image() + # Calling inspect will be removed in 2022.1 after all + # custom components have had a chance to change their signature + sig = inspect.signature(camera.async_camera_image) + if "height" in sig.parameters and "width" in sig.parameters: + image_bytes = await camera.async_camera_image( + width=width, height=height + ) + else: + _LOGGER.warning( + "The camera entity %s does not support requesting width and height, please open an issue with the integration author", + camera.entity_id, + ) + image_bytes = await camera.async_camera_image() - if image: - return Image(camera.content_type, image) + if image_bytes: + content_type = camera.content_type + image = Image(content_type, image_bytes) + if ( + width is not None + and height is not None + and "jpeg" in content_type + or "jpg" in content_type + ): + assert width is not None + assert height is not None + return Image( + content_type, scale_jpeg_camera_image(image, width, height) + ) + + return image raise HomeAssistantError("Unable to get image") +@bind_hass +async def async_get_image( + hass: HomeAssistant, + entity_id: str, + timeout: int = 10, + width: int | None = None, + height: int | None = None, +) -> Image: + """Fetch an image from a camera entity. + + width and height will be passed to the underlying camera. + """ + camera = _get_camera_from_entity_id(hass, entity_id) + return await _async_get_image(camera, timeout, width, height) + + @bind_hass async def async_get_stream_source(hass: HomeAssistant, entity_id: str) -> str | None: """Fetch the stream source for a camera entity.""" @@ -387,12 +439,27 @@ class Camera(Entity): """Return the source of the stream.""" return None - def camera_image(self) -> bytes | None: + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" raise NotImplementedError() - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" + sig = inspect.signature(self.camera_image) + # Calling inspect will be removed in 2022.1 after all + # custom components have had a chance to change their signature + if "height" in sig.parameters and "width" in sig.parameters: + return await self.hass.async_add_executor_job( + partial(self.camera_image, width=width, height=height) + ) + _LOGGER.warning( + "The camera entity %s does not support requesting width and height, please open an issue with the integration author", + self.entity_id, + ) return await self.hass.async_add_executor_job(self.camera_image) async def handle_async_still_stream( @@ -529,14 +596,19 @@ class CameraImageView(CameraView): async def handle(self, request: web.Request, camera: Camera) -> web.Response: """Serve camera image.""" - with suppress(asyncio.CancelledError, asyncio.TimeoutError): - async with async_timeout.timeout(CAMERA_IMAGE_TIMEOUT): - image = await camera.async_camera_image() - - if image: - return web.Response(body=image, content_type=camera.content_type) - - raise web.HTTPInternalServerError() + width = request.query.get("width") + height = request.query.get("height") + try: + image = await _async_get_image( + camera, + CAMERA_IMAGE_TIMEOUT, + int(width) if width else None, + int(height) if height else None, + ) + except (HomeAssistantError, ValueError) as ex: + raise web.HTTPInternalServerError() from ex + else: + return web.Response(body=image.content, content_type=image.content_type) class CameraMjpegStream(CameraView): diff --git a/homeassistant/components/homekit/img_util.py b/homeassistant/components/camera/img_util.py similarity index 72% rename from homeassistant/components/homekit/img_util.py rename to homeassistant/components/camera/img_util.py index 7d7a45081a6..4cfb4fda278 100644 --- a/homeassistant/components/homekit/img_util.py +++ b/homeassistant/components/camera/img_util.py @@ -1,19 +1,32 @@ -"""Image processing for HomeKit component.""" +"""Image processing for cameras.""" import logging +from typing import TYPE_CHECKING, cast SUPPORTED_SCALING_FACTORS = [(7, 8), (3, 4), (5, 8), (1, 2), (3, 8), (1, 4), (1, 8)] _LOGGER = logging.getLogger(__name__) +JPEG_QUALITY = 75 -def scale_jpeg_camera_image(cam_image, width, height): +if TYPE_CHECKING: + from turbojpeg import TurboJPEG + + from . import Image + + +def scale_jpeg_camera_image(cam_image: "Image", width: int, height: int) -> bytes: """Scale a camera image as close as possible to one of the supported scaling factors.""" turbo_jpeg = TurboJPEGSingleton.instance() if not turbo_jpeg: return cam_image.content - (current_width, current_height, _, _) = turbo_jpeg.decode_header(cam_image.content) + try: + (current_width, current_height, _, _) = turbo_jpeg.decode_header( + cam_image.content + ) + except OSError: + return cam_image.content if current_width <= width or current_height <= height: return cam_image.content @@ -26,10 +39,13 @@ def scale_jpeg_camera_image(cam_image, width, height): scaling_factor = supported_sf break - return turbo_jpeg.scale_with_quality( - cam_image.content, - scaling_factor=scaling_factor, - quality=75, + return cast( + bytes, + turbo_jpeg.scale_with_quality( + cam_image.content, + scaling_factor=scaling_factor, + quality=JPEG_QUALITY, + ), ) @@ -45,13 +61,13 @@ class TurboJPEGSingleton: __instance = None @staticmethod - def instance(): + def instance() -> "TurboJPEG": """Singleton for TurboJPEG.""" if TurboJPEGSingleton.__instance is None: TurboJPEGSingleton() return TurboJPEGSingleton.__instance - def __init__(self): + def __init__(self) -> None: """Try to create TurboJPEG only once.""" # pylint: disable=unused-private-member # https://github.com/PyCQA/pylint/issues/4681 diff --git a/homeassistant/components/camera/manifest.json b/homeassistant/components/camera/manifest.json index ed8e10c1956..6a27999c7fe 100644 --- a/homeassistant/components/camera/manifest.json +++ b/homeassistant/components/camera/manifest.json @@ -3,6 +3,7 @@ "name": "Camera", "documentation": "https://www.home-assistant.io/integrations/camera", "dependencies": ["http"], + "requirements": ["PyTurboJPEG==1.5.0"], "after_dependencies": ["media_player"], "codeowners": [], "quality_scale": "internal" diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index 2699ba1f640..7a2d22c2406 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -123,7 +123,9 @@ class CanaryCamera(CoordinatorEntity, Camera): """Return the camera motion detection status.""" return not self.location.is_recording - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" await self.hass.async_add_executor_job(self.renew_live_stream_session) live_stream_url = await self.hass.async_add_executor_job( diff --git a/homeassistant/components/demo/camera.py b/homeassistant/components/demo/camera.py index 56726bba8b7..b3f9b505aee 100644 --- a/homeassistant/components/demo/camera.py +++ b/homeassistant/components/demo/camera.py @@ -1,4 +1,6 @@ """Demo camera platform that has a fake camera.""" +from __future__ import annotations + from pathlib import Path from homeassistant.components.camera import SUPPORT_ON_OFF, Camera @@ -25,7 +27,9 @@ class DemoCamera(Camera): self.is_streaming = True self._images_index = 0 - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes: """Return a faked still image response.""" self._images_index = (self._images_index + 1) % 4 image_path = Path(__file__).parent / f"demo_{self._images_index}.jpg" diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index 53fcdbcee70..16606156314 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -1,4 +1,6 @@ """Support for viewing the camera feed from a DoorBird video doorbell.""" +from __future__ import annotations + import asyncio import datetime import logging @@ -112,7 +114,9 @@ class DoorBirdCamera(DoorBirdEntity, Camera): """Get the name of the camera.""" return self._name - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Pull a still image from the camera.""" now = dt_util.utcnow() diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index 019dcb1aee5..ecd0c562d16 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -1,4 +1,6 @@ """Support for the Environment Canada radar imagery.""" +from __future__ import annotations + import datetime from env_canada import ECRadar @@ -68,7 +70,9 @@ class ECCamera(Camera): self.image = None self.timestamp = None - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" self.update() return self.image diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py index 938d78362f7..47010324290 100644 --- a/homeassistant/components/esphome/camera.py +++ b/homeassistant/components/esphome/camera.py @@ -50,7 +50,9 @@ class EsphomeCamera(Camera, EsphomeEntity[CameraInfo, CameraState]): async with self._image_cond: self._image_cond.notify_all() - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return single camera image bytes.""" if not self.available: return None diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 76fbaee3757..4e5fdb90c79 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -325,7 +325,9 @@ class EzvizCamera(CoordinatorEntity, Camera): """Return the name of this camera.""" return self._serial - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a frame from the camera stream.""" ffmpeg = ImageFrame(self._ffmpeg.binary) diff --git a/homeassistant/components/familyhub/camera.py b/homeassistant/components/familyhub/camera.py index ea654074a5a..65b7a63e419 100644 --- a/homeassistant/components/familyhub/camera.py +++ b/homeassistant/components/familyhub/camera.py @@ -1,4 +1,6 @@ """Family Hub camera for Samsung Refrigerators.""" +from __future__ import annotations + from pyfamilyhublocal import FamilyHubCam import voluptuous as vol @@ -38,7 +40,9 @@ class FamilyHubCamera(Camera): self._name = name self.family_hub_cam = family_hub_cam - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response.""" return await self.family_hub_cam.async_get_cam_image() diff --git a/homeassistant/components/ffmpeg/camera.py b/homeassistant/components/ffmpeg/camera.py index 4cd8b0d1453..323eae7c129 100644 --- a/homeassistant/components/ffmpeg/camera.py +++ b/homeassistant/components/ffmpeg/camera.py @@ -1,4 +1,5 @@ """Support for Cameras with FFmpeg as decoder.""" +from __future__ import annotations from haffmpeg.camera import CameraMjpeg from haffmpeg.tools import IMAGE_JPEG @@ -49,7 +50,9 @@ class FFmpegCamera(Camera): """Return the stream source.""" return self._input.split(" ")[-1] - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" return await async_get_image( self.hass, diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index 31ac8c2cad9..7a1e1037ddb 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -1,4 +1,6 @@ """This component provides basic support for Foscam IP cameras.""" +from __future__ import annotations + import asyncio from libpyfoscam import FoscamCamera @@ -172,7 +174,9 @@ class HassFoscamCamera(Camera): """Return the entity unique ID.""" return self._unique_id - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" # Send the request to snap a picture and return raw jpg data # Handle exception if host is not reachable or url failed diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 56b490e165a..b6e08ea8582 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -1,4 +1,6 @@ """Support for IP Cameras.""" +from __future__ import annotations + import asyncio import logging @@ -118,13 +120,17 @@ class GenericCamera(Camera): """Return the interval between frames of the mjpeg stream.""" return self._frame_interval - def camera_image(self): + 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): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" try: url = self._still_image_url.async_render(parse_result=False) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 187d730de2f..c63ce2a8927 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -6,8 +6,7 @@ "HAP-python==4.0.0", "fnvhash==0.1.0", "PyQRCode==1.2.1", - "base36==0.1.1", - "PyTurboJPEG==1.5.0" + "base36==0.1.1" ], "dependencies": ["http", "camera", "ffmpeg", "network"], "after_dependencies": ["zeroconf"], diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 077366870e2..4a8999ede08 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -55,7 +55,6 @@ from .const import ( SERV_SPEAKER, SERV_STATELESS_PROGRAMMABLE_SWITCH, ) -from .img_util import scale_jpeg_camera_image from .util import pid_is_alive _LOGGER = logging.getLogger(__name__) @@ -467,8 +466,9 @@ class Camera(HomeAccessory, PyhapCamera): async def async_get_snapshot(self, image_size): """Return a jpeg of a snapshot from the camera.""" - return scale_jpeg_camera_image( - await self.hass.components.camera.async_get_image(self.entity_id), - image_size["image-width"], - image_size["image-height"], + image = await self.hass.components.camera.async_get_image( + self.entity_id, + width=image_size["image-width"], + height=image_size["image-height"], ) + return image.content diff --git a/homeassistant/components/homekit_controller/camera.py b/homeassistant/components/homekit_controller/camera.py index fc6a5bb4522..a0b15087356 100644 --- a/homeassistant/components/homekit_controller/camera.py +++ b/homeassistant/components/homekit_controller/camera.py @@ -1,4 +1,6 @@ """Support for Homekit cameras.""" +from __future__ import annotations + from aiohomekit.model.services import ServicesTypes from homeassistant.components.camera import Camera @@ -21,12 +23,14 @@ class HomeKitCamera(AccessoryEntity, Camera): """Return the current state of the camera.""" return "idle" - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a jpeg with the current camera snapshot.""" return await self._accessory.pairing.image( self._aid, - 640, - 480, + width or 640, + height or 480, ) diff --git a/homeassistant/components/hyperion/camera.py b/homeassistant/components/hyperion/camera.py index 22134400a45..809449543af 100644 --- a/homeassistant/components/hyperion/camera.py +++ b/homeassistant/components/hyperion/camera.py @@ -210,7 +210,9 @@ class HyperionCamera(Camera): finally: await self._stop_image_streaming_for_client() - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return single camera image bytes.""" async with self._image_streaming() as is_streaming: if is_streaming: diff --git a/homeassistant/components/local_file/camera.py b/homeassistant/components/local_file/camera.py index 86a075c1a14..6e665ccd1c2 100644 --- a/homeassistant/components/local_file/camera.py +++ b/homeassistant/components/local_file/camera.py @@ -1,4 +1,6 @@ """Camera that loads a picture from a local file.""" +from __future__ import annotations + import logging import mimetypes import os @@ -73,7 +75,9 @@ class LocalFile(Camera): if content is not None: self.content_type = content - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return image response.""" try: with open(self._file_path, "rb") as file: @@ -84,6 +88,7 @@ class LocalFile(Camera): self._name, self._file_path, ) + return None def check_file_path_access(self, file_path): """Check that filepath given is readable.""" diff --git a/homeassistant/components/logi_circle/camera.py b/homeassistant/components/logi_circle/camera.py index 1afeb190c8b..30407f03ecf 100644 --- a/homeassistant/components/logi_circle/camera.py +++ b/homeassistant/components/logi_circle/camera.py @@ -1,4 +1,6 @@ """Support to the Logi Circle cameras.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -142,7 +144,9 @@ class LogiCam(Camera): return state - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image from the camera.""" return await self._camera.live_stream.download_jpeg() diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py index d5008d1778c..d486f78d334 100644 --- a/homeassistant/components/mjpeg/camera.py +++ b/homeassistant/components/mjpeg/camera.py @@ -1,4 +1,6 @@ """Support for IP Cameras.""" +from __future__ import annotations + import asyncio from contextlib import closing import logging @@ -106,7 +108,9 @@ class MjpegCamera(Camera): self._auth = aiohttp.BasicAuth(self._username, password=self._password) self._verify_ssl = device_info.get(CONF_VERIFY_SSL) - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" # DigestAuth is not supported if ( @@ -130,11 +134,17 @@ class MjpegCamera(Camera): except aiohttp.ClientError as err: _LOGGER.error("Error getting new camera image from %s: %s", self._name, err) - def camera_image(self): + return None + + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" if self._username and self._password: if self._authentication == HTTP_DIGEST_AUTHENTICATION: - auth = HTTPDigestAuth(self._username, self._password) + auth: HTTPDigestAuth | HTTPBasicAuth = HTTPDigestAuth( + self._username, self._password + ) else: auth = HTTPBasicAuth(self._username, self._password) req = requests.get( diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index adcb9ca623a..ebd6956e8fd 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -1,4 +1,6 @@ """Camera that loads a picture from an MQTT topic.""" +from __future__ import annotations + import functools import voluptuous as vol @@ -98,6 +100,8 @@ class MqttCamera(MqttEntity, Camera): }, ) - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return image response.""" return self._last_image diff --git a/homeassistant/components/neato/camera.py b/homeassistant/components/neato/camera.py index b6def2cfe38..392d586068d 100644 --- a/homeassistant/components/neato/camera.py +++ b/homeassistant/components/neato/camera.py @@ -66,7 +66,9 @@ class NeatoCleaningMap(Camera): self._image_url: str | None = None self._image: bytes | None = None - def camera_image(self) -> bytes | None: + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return image response.""" self.update() return self._image diff --git a/homeassistant/components/nest/camera_sdm.py b/homeassistant/components/nest/camera_sdm.py index 5f5fdbc8d93..242c6147201 100644 --- a/homeassistant/components/nest/camera_sdm.py +++ b/homeassistant/components/nest/camera_sdm.py @@ -180,7 +180,9 @@ class NestCamera(Camera): self._device.add_update_listener(self.async_write_ha_state) ) - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" # Returns the snapshot of the last event for ~30 seconds after the event active_event_image = await self._async_active_event_image() diff --git a/homeassistant/components/nest/legacy/camera.py b/homeassistant/components/nest/legacy/camera.py index 77629e4dcff..3ef0089d2bc 100644 --- a/homeassistant/components/nest/legacy/camera.py +++ b/homeassistant/components/nest/legacy/camera.py @@ -1,4 +1,6 @@ """Support for Nest Cameras.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -131,7 +133,9 @@ class NestCamera(Camera): def _ready_for_snapshot(self, now): return self._next_snapshot_at is None or now > self._next_snapshot_at - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" now = utcnow() if self._ready_for_snapshot(now): diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 32d0eb46286..4d6141e2dfb 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -194,10 +194,14 @@ class NetatmoCamera(NetatmoBase, Camera): self.data_handler.data[self._data_classes[0]["name"]], ) - async def async_camera_image(self) -> bytes | None: + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" try: - return await self._data.async_get_live_snapshot(camera_id=self._id) + return cast( + bytes, await self._data.async_get_live_snapshot(camera_id=self._id) + ) except ( aiohttp.ClientPayloadError, aiohttp.ContentTypeError, diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 0e95d24ef78..4d80231df23 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -1,4 +1,6 @@ """Support for ONVIF Cameras with FFmpeg as decoder.""" +from __future__ import annotations + import asyncio from haffmpeg.camera import CameraMjpeg @@ -120,7 +122,9 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): """Return the stream source.""" return self._stream_uri - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" image = None diff --git a/homeassistant/components/proxy/camera.py b/homeassistant/components/proxy/camera.py index 8fda507ace2..3c296b7d164 100644 --- a/homeassistant/components/proxy/camera.py +++ b/homeassistant/components/proxy/camera.py @@ -1,4 +1,6 @@ """Proxy camera platform that enables image processing of camera data.""" +from __future__ import annotations + import asyncio from datetime import timedelta import io @@ -219,13 +221,17 @@ class ProxyCamera(Camera): self._last_image = None self._mode = config.get(CONF_MODE) - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return camera image.""" return asyncio.run_coroutine_threadsafe( self.async_camera_image(), self.hass.loop ).result() - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" now = dt_util.utcnow() @@ -244,13 +250,13 @@ class ProxyCamera(Camera): job = _resize_image else: job = _crop_image - image = await self.hass.async_add_executor_job( + image_bytes: bytes = await self.hass.async_add_executor_job( job, image.content, self._image_opts ) if self._cache_images: - self._last_image = image - return image + self._last_image = image_bytes + return image_bytes async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from camera images.""" diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py index ff0ac45c139..8f4d1d04dcf 100644 --- a/homeassistant/components/push/camera.py +++ b/homeassistant/components/push/camera.py @@ -1,4 +1,6 @@ """Camera platform that receives images through HTTP POST.""" +from __future__ import annotations + import asyncio from collections import deque from datetime import timedelta @@ -155,7 +157,9 @@ class PushCamera(Camera): self.async_write_ha_state() - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response.""" if self.queue: if self._state == STATE_IDLE: diff --git a/homeassistant/components/qvr_pro/camera.py b/homeassistant/components/qvr_pro/camera.py index 2f4353063d1..cac288eaef0 100644 --- a/homeassistant/components/qvr_pro/camera.py +++ b/homeassistant/components/qvr_pro/camera.py @@ -1,4 +1,5 @@ """Support for QVR Pro streams.""" +from __future__ import annotations import logging @@ -88,7 +89,9 @@ class QVRProCamera(Camera): return attrs - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Get image bytes from camera.""" try: return self._client.get_snapshot(self.guid) diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 580fc71e141..77317d62ab3 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -1,4 +1,6 @@ """This component provides support to the Ring Door Bell camera.""" +from __future__ import annotations + import asyncio from datetime import timedelta from itertools import chain @@ -101,7 +103,9 @@ class RingCam(RingEntityMixin, Camera): "last_video_id": self._last_video_id, } - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" ffmpeg = ImageFrame(self._ffmpeg.binary) diff --git a/homeassistant/components/rpi_camera/camera.py b/homeassistant/components/rpi_camera/camera.py index 070e861b3c9..980586d4def 100644 --- a/homeassistant/components/rpi_camera/camera.py +++ b/homeassistant/components/rpi_camera/camera.py @@ -1,4 +1,6 @@ """Camera platform that has a Raspberry Pi camera.""" +from __future__ import annotations + import logging import os import shutil @@ -122,7 +124,9 @@ class RaspberryCamera(Camera): ): pass - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return raspistill image response.""" with open(self._config[CONF_FILE_PATH], "rb") as file: return file.read() diff --git a/homeassistant/components/skybell/camera.py b/homeassistant/components/skybell/camera.py index 87dc3c0bf8d..20e93fb90f7 100644 --- a/homeassistant/components/skybell/camera.py +++ b/homeassistant/components/skybell/camera.py @@ -1,4 +1,6 @@ """Camera support for the Skybell HD Doorbell.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -75,7 +77,9 @@ class SkybellCamera(SkybellDevice, Camera): return self._device.activity_image return self._device.image - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Get the latest camera image.""" super().update() diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index 8341b8b121a..d609a434ae2 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -123,7 +123,9 @@ class SynoDSMCamera(SynologyDSMBaseEntity, Camera): """Return the camera motion detection status.""" return self.camera_data.is_motion_detection_enabled # type: ignore[no-any-return] - def camera_image(self) -> bytes | None: + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" _LOGGER.debug( "SynoDSMCamera.camera_image(%s)", diff --git a/homeassistant/components/uvc/camera.py b/homeassistant/components/uvc/camera.py index 74bf175f75a..77ff6a30f95 100644 --- a/homeassistant/components/uvc/camera.py +++ b/homeassistant/components/uvc/camera.py @@ -194,10 +194,12 @@ class UnifiVideoCamera(Camera): self._caminfo = caminfo return True - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return the image of this camera.""" if not self._camera and not self._login(): - return + return None def _get_image(retry=True): try: diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py index a137f61d98f..455d7070a8b 100644 --- a/homeassistant/components/verisure/camera.py +++ b/homeassistant/components/verisure/camera.py @@ -79,7 +79,9 @@ class VerisureSmartcam(CoordinatorEntity, Camera): "via_device": (DOMAIN, self.coordinator.entry.data[CONF_GIID]), } - def camera_image(self) -> bytes | None: + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return image response.""" self.check_imagelist() if not self._image: diff --git a/homeassistant/components/vivotek/camera.py b/homeassistant/components/vivotek/camera.py index 953d64f0ff6..b813d337e82 100644 --- a/homeassistant/components/vivotek/camera.py +++ b/homeassistant/components/vivotek/camera.py @@ -1,4 +1,6 @@ """Support for Vivotek IP Cameras.""" +from __future__ import annotations + from libpyvivotek import VivotekCamera import voluptuous as vol @@ -87,7 +89,9 @@ class VivotekCam(Camera): """Return the interval between frames of the mjpeg stream.""" return self._frame_interval - def camera_image(self): + def camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return bytes of camera image.""" return self._cam.snapshot() diff --git a/homeassistant/components/xeoma/camera.py b/homeassistant/components/xeoma/camera.py index d6f313c0382..049b4bfcbc0 100644 --- a/homeassistant/components/xeoma/camera.py +++ b/homeassistant/components/xeoma/camera.py @@ -1,4 +1,6 @@ """Support for Xeoma Cameras.""" +from __future__ import annotations + import logging from pyxeoma.xeoma import Xeoma, XeomaError @@ -109,7 +111,9 @@ class XeomaCamera(Camera): self._password = password self._last_image = None - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" try: diff --git a/homeassistant/components/xiaomi/camera.py b/homeassistant/components/xiaomi/camera.py index 359d6c8b896..c89b23e9081 100644 --- a/homeassistant/components/xiaomi/camera.py +++ b/homeassistant/components/xiaomi/camera.py @@ -1,4 +1,6 @@ """This component provides support for Xiaomi Cameras.""" +from __future__ import annotations + import asyncio from ftplib import FTP, error_perm import logging @@ -138,7 +140,9 @@ class XiaomiCamera(Camera): return f"ftp://{self.user}:{self.passwd}@{host}:{self.port}{ftp.pwd()}/{video}" - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" try: diff --git a/homeassistant/components/yi/camera.py b/homeassistant/components/yi/camera.py index c130532a2e1..6f898bb9a9b 100644 --- a/homeassistant/components/yi/camera.py +++ b/homeassistant/components/yi/camera.py @@ -1,4 +1,6 @@ """Support for Xiaomi Cameras (HiSilicon Hi3518e V200).""" +from __future__ import annotations + import asyncio import logging @@ -119,7 +121,9 @@ class YiCamera(Camera): self._is_on = False return None - async def async_camera_image(self): + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: """Return a still image response from the camera.""" url = await self._get_latest_video_url() if url and url != self._last_url: diff --git a/requirements_all.txt b/requirements_all.txt index 813f40acf54..901ac8fd3b8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -57,7 +57,7 @@ PySocks==1.7.1 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 -# homeassistant.components.homekit +# homeassistant.components.camera PyTurboJPEG==1.5.0 # homeassistant.components.vicare diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4bc6cb76018..37ee164cd60 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -26,7 +26,7 @@ PyRMVtransport==0.3.2 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 -# homeassistant.components.homekit +# homeassistant.components.camera PyTurboJPEG==1.5.0 # homeassistant.components.xiaomi_aqara diff --git a/tests/components/camera/common.py b/tests/components/camera/common.py index 93e2596e343..756a553f3c7 100644 --- a/tests/components/camera/common.py +++ b/tests/components/camera/common.py @@ -3,8 +3,12 @@ All containing methods are legacy helpers that should not be used by new components. Instead call the service directly. """ +from unittest.mock import Mock + from homeassistant.components.camera.const import DATA_CAMERA_PREFS, PREF_PRELOAD_STREAM +EMPTY_8_6_JPEG = b"empty_8_6" + def mock_camera_prefs(hass, entity_id, prefs=None): """Fixture for cloud component.""" @@ -13,3 +17,16 @@ def mock_camera_prefs(hass, entity_id, prefs=None): prefs_to_set.update(prefs) hass.data[DATA_CAMERA_PREFS]._prefs[entity_id] = prefs_to_set return prefs_to_set + + +def mock_turbo_jpeg( + first_width=None, second_width=None, first_height=None, second_height=None +): + """Mock a TurboJPEG instance.""" + mocked_turbo_jpeg = Mock() + mocked_turbo_jpeg.decode_header.side_effect = [ + (first_width, first_height, 0, 0), + (second_width, second_height, 0, 0), + ] + mocked_turbo_jpeg.scale_with_quality.return_value = EMPTY_8_6_JPEG + return mocked_turbo_jpeg diff --git a/tests/components/homekit/test_img_util.py b/tests/components/camera/test_img_util.py similarity index 67% rename from tests/components/homekit/test_img_util.py rename to tests/components/camera/test_img_util.py index 45af8e6b1e6..4f32715800e 100644 --- a/tests/components/homekit/test_img_util.py +++ b/tests/components/camera/test_img_util.py @@ -1,8 +1,10 @@ -"""Test HomeKit img_util module.""" +"""Test img_util module.""" from unittest.mock import patch +from turbojpeg import TurboJPEG + from homeassistant.components.camera import Image -from homeassistant.components.homekit.img_util import ( +from homeassistant.components.camera.img_util import ( TurboJPEGSingleton, scale_jpeg_camera_image, ) @@ -12,13 +14,23 @@ from .common import EMPTY_8_6_JPEG, mock_turbo_jpeg EMPTY_16_12_JPEG = b"empty_16_12" +def _clear_turbojpeg_singleton(): + TurboJPEGSingleton.__instance = None + + +def _reset_turbojpeg_singleton(): + TurboJPEGSingleton.__instance = TurboJPEG() + + def test_turbojpeg_singleton(): """Verify the instance always gives back the same.""" + _clear_turbojpeg_singleton() assert TurboJPEGSingleton.instance() == TurboJPEGSingleton.instance() def test_scale_jpeg_camera_image(): """Test we can scale a jpeg image.""" + _clear_turbojpeg_singleton() camera_image = Image("image/jpeg", EMPTY_16_12_JPEG) @@ -27,6 +39,12 @@ def test_scale_jpeg_camera_image(): TurboJPEGSingleton() assert scale_jpeg_camera_image(camera_image, 16, 12) == camera_image.content + turbo_jpeg = mock_turbo_jpeg(first_width=16, first_height=12) + turbo_jpeg.decode_header.side_effect = OSError + with patch("turbojpeg.TurboJPEG", return_value=turbo_jpeg): + TurboJPEGSingleton() + assert scale_jpeg_camera_image(camera_image, 16, 12) == camera_image.content + turbo_jpeg = mock_turbo_jpeg(first_width=16, first_height=12) with patch("turbojpeg.TurboJPEG", return_value=turbo_jpeg): TurboJPEGSingleton() @@ -44,11 +62,11 @@ def test_scale_jpeg_camera_image(): def test_turbojpeg_load_failure(): """Handle libjpegturbo not being installed.""" - + _clear_turbojpeg_singleton() with patch("turbojpeg.TurboJPEG", side_effect=Exception): TurboJPEGSingleton() assert TurboJPEGSingleton.instance() is False - with patch("turbojpeg.TurboJPEG"): - TurboJPEGSingleton() - assert TurboJPEGSingleton.instance() + _clear_turbojpeg_singleton() + TurboJPEGSingleton() + assert TurboJPEGSingleton.instance() is not None diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 7c7890a3e5f..f4267de234c 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -20,6 +20,8 @@ from homeassistant.const import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component +from .common import EMPTY_8_6_JPEG, mock_turbo_jpeg + from tests.components.camera import common @@ -75,6 +77,51 @@ async def test_get_image_from_camera(hass, image_mock_url): assert image.content == b"Test" +async def test_get_image_from_camera_with_width_height(hass, image_mock_url): + """Grab an image from camera entity with width and height.""" + + turbo_jpeg = mock_turbo_jpeg( + first_width=16, first_height=12, second_width=300, second_height=200 + ) + with patch( + "homeassistant.components.camera.img_util.TurboJPEGSingleton.instance", + return_value=turbo_jpeg, + ), patch( + "homeassistant.components.demo.camera.Path.read_bytes", + autospec=True, + return_value=b"Test", + ) as mock_camera: + image = await camera.async_get_image( + hass, "camera.demo_camera", width=640, height=480 + ) + + assert mock_camera.called + assert image.content == b"Test" + + +async def test_get_image_from_camera_with_width_height_scaled(hass, image_mock_url): + """Grab an image from camera entity with width and height and scale it.""" + + turbo_jpeg = mock_turbo_jpeg( + first_width=16, first_height=12, second_width=300, second_height=200 + ) + with patch( + "homeassistant.components.camera.img_util.TurboJPEGSingleton.instance", + return_value=turbo_jpeg, + ), patch( + "homeassistant.components.demo.camera.Path.read_bytes", + autospec=True, + return_value=b"Valid jpeg", + ) as mock_camera: + image = await camera.async_get_image( + hass, "camera.demo_camera", width=4, height=3 + ) + + assert mock_camera.called + assert image.content_type == "image/jpeg" + assert image.content == EMPTY_8_6_JPEG + + async def test_get_stream_source_from_camera(hass, mock_camera): """Fetch stream source from camera entity.""" diff --git a/tests/components/homekit/common.py b/tests/components/homekit/common.py deleted file mode 100644 index 6b1d87e3f54..00000000000 --- a/tests/components/homekit/common.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Collection of fixtures and functions for the HomeKit tests.""" -from unittest.mock import Mock - -EMPTY_8_6_JPEG = b"empty_8_6" - - -def mock_turbo_jpeg( - first_width=None, second_width=None, first_height=None, second_height=None -): - """Mock a TurboJPEG instance.""" - mocked_turbo_jpeg = Mock() - mocked_turbo_jpeg.decode_header.side_effect = [ - (first_width, first_height, 0, 0), - (second_width, second_height, 0, 0), - ] - mocked_turbo_jpeg.scale_with_quality.return_value = EMPTY_8_6_JPEG - return mocked_turbo_jpeg diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index b9df572a699..991965b30b5 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -7,6 +7,7 @@ from pyhap.accessory_driver import AccessoryDriver import pytest from homeassistant.components import camera, ffmpeg +from homeassistant.components.camera.img_util import TurboJPEGSingleton from homeassistant.components.homekit.accessories import HomeBridge from homeassistant.components.homekit.const import ( AUDIO_CODEC_COPY, @@ -26,14 +27,13 @@ from homeassistant.components.homekit.const import ( VIDEO_CODEC_COPY, VIDEO_CODEC_H264_OMX, ) -from homeassistant.components.homekit.img_util import TurboJPEGSingleton from homeassistant.components.homekit.type_cameras import Camera from homeassistant.components.homekit.type_switches import Switch from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -from .common import mock_turbo_jpeg +from tests.components.camera.common import mock_turbo_jpeg MOCK_START_STREAM_TLV = "ARUCAQEBEDMD1QMXzEaatnKSQ2pxovYCNAEBAAIJAQECAgECAwEAAwsBAgAFAgLQAgMBHgQXAQFjAgQ768/RAwIrAQQEAAAAPwUCYgUDLAEBAwIMAQEBAgEAAwECBAEUAxYBAW4CBCzq28sDAhgABAQAAKBABgENBAEA" MOCK_END_POINTS_TLV = "ARAzA9UDF8xGmrZykkNqcaL2AgEAAxoBAQACDTE5Mi4xNjguMjA4LjUDAi7IBAKkxwQlAQEAAhDN0+Y0tZ4jzoO0ske9UsjpAw6D76oVXnoi7DbawIG4CwUlAQEAAhCyGcROB8P7vFRDzNF2xrK1Aw6NdcLugju9yCfkWVSaVAYEDoAsAAcEpxV8AA=="