"""Proxy camera platform that enables image processing of camera data.""" from __future__ import annotations import asyncio from datetime import timedelta import io import logging from PIL import Image import voluptuous as vol from homeassistant.components.camera import ( PLATFORM_SCHEMA, Camera, async_get_image, async_get_mjpeg_stream, async_get_still_stream, ) from homeassistant.const import CONF_ENTITY_ID, CONF_MODE, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) CONF_CACHE_IMAGES = "cache_images" CONF_FORCE_RESIZE = "force_resize" CONF_IMAGE_QUALITY = "image_quality" CONF_IMAGE_REFRESH_RATE = "image_refresh_rate" CONF_MAX_IMAGE_WIDTH = "max_image_width" CONF_MAX_IMAGE_HEIGHT = "max_image_height" CONF_MAX_STREAM_WIDTH = "max_stream_width" CONF_MAX_STREAM_HEIGHT = "max_stream_height" CONF_IMAGE_TOP = "image_top" CONF_IMAGE_LEFT = "image_left" CONF_STREAM_QUALITY = "stream_quality" MODE_RESIZE = "resize" MODE_CROP = "crop" DEFAULT_BASENAME = "Camera Proxy" DEFAULT_QUALITY = 75 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_CACHE_IMAGES, False): cv.boolean, vol.Optional(CONF_FORCE_RESIZE, False): cv.boolean, vol.Optional(CONF_MODE, default=MODE_RESIZE): vol.In([MODE_RESIZE, MODE_CROP]), vol.Optional(CONF_IMAGE_QUALITY): int, vol.Optional(CONF_IMAGE_REFRESH_RATE): float, vol.Optional(CONF_MAX_IMAGE_WIDTH): int, vol.Optional(CONF_MAX_IMAGE_HEIGHT): int, vol.Optional(CONF_MAX_STREAM_WIDTH): int, vol.Optional(CONF_MAX_STREAM_HEIGHT): int, vol.Optional(CONF_IMAGE_LEFT): int, vol.Optional(CONF_IMAGE_TOP): int, vol.Optional(CONF_STREAM_QUALITY): int, } ) async def async_setup_platform( hass: HomeAssistant, config: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Proxy camera platform.""" async_add_entities([ProxyCamera(hass, config)]) def _precheck_image(image, opts): """Perform some pre-checks on the given image.""" if not opts: raise ValueError() try: img = Image.open(io.BytesIO(image)) except OSError as err: _LOGGER.warning("Failed to open image") raise ValueError() from err imgfmt = str(img.format) if imgfmt not in ("PNG", "JPEG"): _LOGGER.warning("Image is of unsupported type: %s", imgfmt) raise ValueError() if img.mode != "RGB": img = img.convert("RGB") return img def _resize_image(image, opts): """Resize image.""" try: img = _precheck_image(image, opts) except ValueError: return image quality = opts.quality or DEFAULT_QUALITY new_width = opts.max_width (old_width, old_height) = img.size old_size = len(image) if old_width <= new_width: if opts.quality is None: _LOGGER.debug("Image is smaller-than/equal-to requested width") return image new_width = old_width scale = new_width / float(old_width) new_height = int(float(old_height) * float(scale)) img = img.resize((new_width, new_height), Image.ANTIALIAS) imgbuf = io.BytesIO() img.save(imgbuf, "JPEG", optimize=True, quality=quality) newimage = imgbuf.getvalue() if not opts.force_resize and len(newimage) >= old_size: _LOGGER.debug( "Using original image (%d bytes) " "because resized image (%d bytes) is not smaller", old_size, len(newimage), ) return image _LOGGER.debug( "Resized image from (%dx%d - %d bytes) to (%dx%d - %d bytes)", old_width, old_height, old_size, new_width, new_height, len(newimage), ) return newimage def _crop_image(image, opts): """Crop image.""" try: img = _precheck_image(image, opts) except ValueError: return image quality = opts.quality or DEFAULT_QUALITY (old_width, old_height) = img.size old_size = len(image) if opts.top is None: opts.top = 0 if opts.left is None: opts.left = 0 if opts.max_width is None or opts.max_width > old_width - opts.left: opts.max_width = old_width - opts.left if opts.max_height is None or opts.max_height > old_height - opts.top: opts.max_height = old_height - opts.top img = img.crop( (opts.left, opts.top, opts.left + opts.max_width, opts.top + opts.max_height) ) imgbuf = io.BytesIO() img.save(imgbuf, "JPEG", optimize=True, quality=quality) newimage = imgbuf.getvalue() _LOGGER.debug( "Cropped image from (%dx%d - %d bytes) to (%dx%d - %d bytes)", old_width, old_height, old_size, opts.max_width, opts.max_height, len(newimage), ) return newimage class ImageOpts: """The representation of image options.""" def __init__(self, max_width, max_height, left, top, quality, force_resize): """Initialize image options.""" self.max_width = max_width self.max_height = max_height self.left = left self.top = top self.quality = quality self.force_resize = force_resize def __bool__(self): """Bool evaluation rules.""" return bool(self.max_width or self.quality) class ProxyCamera(Camera): """The representation of a Proxy camera.""" def __init__(self, hass, config): """Initialize a proxy camera component.""" super().__init__() self.hass = hass self._proxied_camera = config.get(CONF_ENTITY_ID) self._name = ( config.get(CONF_NAME) or f"{DEFAULT_BASENAME} - {self._proxied_camera}" ) self._image_opts = ImageOpts( config.get(CONF_MAX_IMAGE_WIDTH), config.get(CONF_MAX_IMAGE_HEIGHT), config.get(CONF_IMAGE_LEFT), config.get(CONF_IMAGE_TOP), config.get(CONF_IMAGE_QUALITY), config.get(CONF_FORCE_RESIZE), ) self._stream_opts = ImageOpts( config.get(CONF_MAX_STREAM_WIDTH), config.get(CONF_MAX_STREAM_HEIGHT), config.get(CONF_IMAGE_LEFT), config.get(CONF_IMAGE_TOP), config.get(CONF_STREAM_QUALITY), True, ) self._image_refresh_rate = config.get(CONF_IMAGE_REFRESH_RATE) self._cache_images = bool( config.get(CONF_IMAGE_REFRESH_RATE) or config.get(CONF_CACHE_IMAGES) ) self._last_image_time = dt_util.utc_from_timestamp(0) self._last_image = None self._mode = config.get(CONF_MODE) 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, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" now = dt_util.utcnow() if self._image_refresh_rate and now < self._last_image_time + timedelta( seconds=self._image_refresh_rate ): return self._last_image self._last_image_time = now image = await async_get_image(self.hass, self._proxied_camera) if not image: _LOGGER.error("Error getting original camera image") return self._last_image if self._mode == MODE_RESIZE: job = _resize_image else: job = _crop_image image_bytes: bytes = await self.hass.async_add_executor_job( job, image.content, self._image_opts ) if self._cache_images: self._last_image = image_bytes return image_bytes async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from camera images.""" if not self._stream_opts: return await async_get_mjpeg_stream( self.hass, request, self._proxied_camera ) return await async_get_still_stream( request, self._async_stream_image, self.content_type, self.frame_interval ) @property def name(self): """Return the name of this camera.""" return self._name async def _async_stream_image(self): """Return a still image response from the camera.""" try: image = await async_get_image(self.hass, self._proxied_camera) if not image: return None except HomeAssistantError as err: raise asyncio.CancelledError() from err if self._mode == MODE_RESIZE: job = _resize_image else: job = _crop_image return await self.hass.async_add_executor_job( job, image.content, self._stream_opts )