"""Proxy camera platform that enables image processing of camera data.""" import asyncio import logging from datetime import timedelta import voluptuous as vol from homeassistant.components.camera import PLATFORM_SCHEMA, Camera from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_MODE from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.util.async_ import run_coroutine_threadsafe 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, config, async_add_entities, discovery_info=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.""" from PIL import Image import io if not opts: raise ValueError() try: img = Image.open(io.BytesIO(image)) except OSError: _LOGGER.warning("Failed to open image") raise ValueError() imgfmt = str(img.format) if imgfmt not in ("PNG", "JPEG"): _LOGGER.warning("Image is of unsupported type: %s", imgfmt) raise ValueError() return img def _resize_image(image, opts): """Resize image.""" from PIL import Image import io 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.""" import io 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 "{} - {}".format( 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): """Return camera image.""" return run_coroutine_threadsafe( self.async_camera_image(), self.hass.loop ).result() async def async_camera_image(self): """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 self.hass.components.camera.async_get_image(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 = await self.hass.async_add_executor_job( job, image.content, self._image_opts ) if self._cache_images: self._last_image = image return image async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from camera images.""" if not self._stream_opts: return await self.hass.components.camera.async_get_mjpeg_stream( request, self._proxied_camera ) return await self.hass.components.camera.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 self.hass.components.camera.async_get_image( self._proxied_camera ) if not image: return None except HomeAssistantError: raise asyncio.CancelledError() 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 )