core/homeassistant/components/camera/proxy.py

280 lines
9.0 KiB
Python

"""
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)