From 8200827a19c43a9d38428b3ec6eae7946208d8b5 Mon Sep 17 00:00:00 2001 From: Colin O'Dell Date: Sun, 15 Jan 2017 14:37:24 -0500 Subject: [PATCH] Add support for direct MJPEG streams from Amcrest cameras (#5217) * Add support for direct MJPEG streams from Amcrest cameras The previous implementation relied on using snapshots from the camera. However, some Amcrest models cannot keep up with the large number of requests and instead timeout, resulting in no video stream. These cameras do provide MJPEG streams though, so this commit adds the option to use these instead of creating one from snapshots. Unfortunately, some cameras on newer firmwares do not support MJPEG streams at high resolution - only at low resolution. By providing users with both a `resolution` and `stream_source` option, we can allow them to choose whichever combination works for their particular model and firmware version. * Close the stream instead of releasing it * Close stream without creating a task * Handle client aborts * fix lint --- homeassistant/components/camera/amcrest.py | 84 +++++++++++++++++++++- 1 file changed, 82 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/camera/amcrest.py b/homeassistant/components/camera/amcrest.py index c6568677583..a3d8dcf35df 100644 --- a/homeassistant/components/camera/amcrest.py +++ b/homeassistant/components/camera/amcrest.py @@ -4,8 +4,13 @@ This component provides basic support for Amcrest IP cameras. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/camera.amcrest/ """ +import asyncio import logging +import aiohttp +from aiohttp import web +from aiohttp.web_exceptions import HTTPGatewayTimeout +import async_timeout import voluptuous as vol import homeassistant.loader as loader @@ -13,16 +18,19 @@ from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA) from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_PORT) from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_create_clientsession REQUIREMENTS = ['amcrest==1.0.0'] _LOGGER = logging.getLogger(__name__) CONF_RESOLUTION = 'resolution' +CONF_STREAM_SOURCE = 'stream_source' DEFAULT_NAME = 'Amcrest Camera' DEFAULT_PORT = 80 DEFAULT_RESOLUTION = 'high' +DEFAULT_STREAM_SOURCE = 'mjpeg' NOTIFICATION_ID = 'amcrest_notification' NOTIFICATION_TITLE = 'Amcrest Camera Setup' @@ -32,6 +40,14 @@ RESOLUTION_LIST = { 'low': 1, } +STREAM_SOURCE_LIST = { + 'mjpeg': 0, + 'snapshot': 1 +} + +CONTENT_TYPE_HEADER = 'Content-Type' +TIMEOUT = 5 + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, @@ -40,6 +56,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.All(vol.In(RESOLUTION_LIST)), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_STREAM_SOURCE, default=DEFAULT_STREAM_SOURCE): + vol.All(vol.In(STREAM_SOURCE_LIST)), }) @@ -64,19 +82,33 @@ def setup_platform(hass, config, add_devices, discovery_info=None): notification_id=NOTIFICATION_ID) return False - add_devices([AmcrestCam(config, data)]) + add_devices([AmcrestCam(hass, config, data)]) return True class AmcrestCam(Camera): """An implementation of an Amcrest IP camera.""" - def __init__(self, device_info, data): + def __init__(self, hass, device_info, data): """Initialize an Amcrest camera.""" super(AmcrestCam, self).__init__() + self._base_url = '%s://%s:%s/cgi-bin' % ( + 'http', + device_info.get(CONF_HOST), + device_info.get(CONF_PORT) + ) self._data = data + self._hass = hass self._name = device_info.get(CONF_NAME) self._resolution = RESOLUTION_LIST[device_info.get(CONF_RESOLUTION)] + self._stream_source = STREAM_SOURCE_LIST[ + device_info.get(CONF_STREAM_SOURCE) + ] + self._token = self._auth = aiohttp.BasicAuth( + device_info.get(CONF_USERNAME), + password=device_info.get(CONF_PASSWORD) + ) + self._websession = async_create_clientsession(hass) def camera_image(self): """Return a still image reponse from the camera.""" @@ -84,6 +116,54 @@ class AmcrestCam(Camera): response = self._data.camera.snapshot(channel=self._resolution) return response.data + @asyncio.coroutine + def handle_async_mjpeg_stream(self, request): + """Return an MJPEG stream.""" + # The snapshot implementation is handled by the parent class + if self._stream_source == STREAM_SOURCE_LIST['snapshot']: + yield from super().handle_async_mjpeg_stream(request) + return + + # Otherwise, stream an MJPEG image stream directly from the camera + streaming_url = '%s/mjpg/video.cgi?channel=0&subtype=%d' % ( + self._base_url, + self._resolution + ) + + stream = None + response = None + try: + with async_timeout.timeout(TIMEOUT, loop=self.hass.loop): + stream = yield from self._websession.get( + streaming_url, + auth=self._token, + timeout=TIMEOUT + ) + response = web.StreamResponse() + response.content_type = stream.headers.get(CONTENT_TYPE_HEADER) + + yield from response.prepare(request) + + while True: + data = yield from stream.content.read(16384) + if not data: + break + response.write(data) + + except (asyncio.TimeoutError, aiohttp.errors.ClientError): + _LOGGER.exception("Error on %s", streaming_url) + raise HTTPGatewayTimeout() + + except asyncio.CancelledError: + _LOGGER.debug("Close stream by frontend.") + response = None + + finally: + if stream is not None: + stream.close() + if response is not None: + yield from response.write_eof() + @property def name(self): """Return the name of this camera."""