""" Proxy camera platform that enables image processing of camera data. For more details about this platform, please refer to the documentation https://www.home-assistant.io/components/camera.proxy/ """ 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, \ HTTP_HEADER_HA_AUTH 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 from homeassistant.components.camera import async_get_still_stream REQUIREMENTS = ['pillow==5.4.1'] _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 IOError: _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._headers = ( {HTTP_HEADER_HA_AUTH: self.hass.config.api.api_password} if self.hass.config.api.api_password is not None else 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 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)