diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 3a0a983fceb..6de31caa90e 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -5,16 +5,30 @@ from datetime import timedelta import aiohttp import voluptuous as vol +from homeassistant.auth.permissions.const import POLICY_CONTROL +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR +from homeassistant.components.camera import DOMAIN as CAMERA +from homeassistant.components.sensor import DOMAIN as SENSOR +from homeassistant.components.switch import DOMAIN as SWITCH from homeassistant.const import ( - CONF_NAME, CONF_HOST, CONF_PORT, CONF_USERNAME, CONF_PASSWORD, - CONF_BINARY_SENSORS, CONF_SENSORS, CONF_SWITCHES, CONF_SCAN_INTERVAL, - HTTP_BASIC_AUTHENTICATION) + ATTR_ENTITY_ID, CONF_AUTHENTICATION, CONF_BINARY_SENSORS, CONF_HOST, + CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL, CONF_SENSORS, + CONF_SWITCHES, CONF_USERNAME, ENTITY_MATCH_ALL, HTTP_BASIC_AUTHENTICATION) +from homeassistant.exceptions import Unauthorized, UnknownUser from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.service import async_extract_entity_ids + +from .binary_sensor import BINARY_SENSORS +from .camera import CAMERA_SERVICES, STREAM_SOURCE_LIST +from .const import DOMAIN, DATA_AMCREST +from .helpers import service_signal +from .sensor import SENSOR_MOTION_DETECTOR, SENSORS +from .switch import SWITCHES _LOGGER = logging.getLogger(__name__) -CONF_AUTHENTICATION = 'authentication' CONF_RESOLUTION = 'resolution' CONF_STREAM_SOURCE = 'stream_source' CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' @@ -22,12 +36,7 @@ CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' DEFAULT_NAME = 'Amcrest Camera' DEFAULT_PORT = 80 DEFAULT_RESOLUTION = 'high' -DEFAULT_STREAM_SOURCE = 'snapshot' DEFAULT_ARGUMENTS = '-pred 1' -TIMEOUT = 10 - -DATA_AMCREST = 'amcrest' -DOMAIN = 'amcrest' NOTIFICATION_ID = 'amcrest_notification' NOTIFICATION_TITLE = 'Amcrest Camera Setup' @@ -43,70 +52,60 @@ AUTHENTICATION_LIST = { 'basic': 'basic' } -STREAM_SOURCE_LIST = { - 'mjpeg': 0, - 'snapshot': 1, - 'rtsp': 2, -} -BINARY_SENSORS = { - 'motion_detected': 'Motion Detected' -} - -# Sensor types are defined like: Name, units, icon -SENSOR_MOTION_DETECTOR = 'motion_detector' -SENSORS = { - SENSOR_MOTION_DETECTOR: ['Motion Detected', None, 'mdi:run'], - 'sdcard': ['SD Used', '%', 'mdi:sd'], - 'ptz_preset': ['PTZ Preset', None, 'mdi:camera-iris'], -} - -# Switch types are defined like: Name, icon -SWITCHES = { - 'motion_detection': ['Motion Detection', 'mdi:run-fast'], - 'motion_recording': ['Motion Recording', 'mdi:record-rec'] -} - - -def _deprecated_sensors(value): - if SENSOR_MOTION_DETECTOR in value: +def _deprecated_sensor_values(sensors): + if SENSOR_MOTION_DETECTOR in sensors: _LOGGER.warning( - 'sensors option %s is deprecated. ' - 'Please remove from your configuration and ' - 'use binary_sensors option motion_detected instead.', - SENSOR_MOTION_DETECTOR) - return value + "The 'sensors' option value '%s' is deprecated, " + "please remove it from your configuration and use " + "the 'binary_sensors' option with value 'motion_detected' " + "instead.", SENSOR_MOTION_DETECTOR) + return sensors -def _has_unique_names(value): - names = [camera[CONF_NAME] for camera in value] +def _deprecated_switches(config): + if CONF_SWITCHES in config: + _LOGGER.warning( + "The 'switches' option (with value %s) is deprecated, " + "please remove it from your configuration and use " + "camera services and attributes instead.", + config[CONF_SWITCHES]) + return config + + +def _has_unique_names(devices): + names = [device[CONF_NAME] for device in devices] vol.Schema(vol.Unique())(names) - return value + return devices -AMCREST_SCHEMA = vol.Schema({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION): - vol.All(vol.In(AUTHENTICATION_LIST)), - vol.Optional(CONF_RESOLUTION, default=DEFAULT_RESOLUTION): - vol.All(vol.In(RESOLUTION_LIST)), - vol.Optional(CONF_STREAM_SOURCE, default=DEFAULT_STREAM_SOURCE): - vol.All(vol.In(STREAM_SOURCE_LIST)), - vol.Optional(CONF_FFMPEG_ARGUMENTS, default=DEFAULT_ARGUMENTS): - cv.string, - vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): - cv.time_period, - vol.Optional(CONF_BINARY_SENSORS): - vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)]), - vol.Optional(CONF_SENSORS): - vol.All(cv.ensure_list, [vol.In(SENSORS)], _deprecated_sensors), - vol.Optional(CONF_SWITCHES): - vol.All(cv.ensure_list, [vol.In(SWITCHES)]), -}) +AMCREST_SCHEMA = vol.All( + vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION): + vol.All(vol.In(AUTHENTICATION_LIST)), + vol.Optional(CONF_RESOLUTION, default=DEFAULT_RESOLUTION): + vol.All(vol.In(RESOLUTION_LIST)), + vol.Optional(CONF_STREAM_SOURCE, default=STREAM_SOURCE_LIST[0]): + vol.All(vol.In(STREAM_SOURCE_LIST)), + vol.Optional(CONF_FFMPEG_ARGUMENTS, default=DEFAULT_ARGUMENTS): + cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): + cv.time_period, + vol.Optional(CONF_BINARY_SENSORS): + vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)]), + vol.Optional(CONF_SENSORS): + vol.All(cv.ensure_list, [vol.In(SENSORS)], + _deprecated_sensor_values), + vol.Optional(CONF_SWITCHES): + vol.All(cv.ensure_list, [vol.In(SWITCHES)]), + }), + _deprecated_switches +) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.All(cv.ensure_list, [AMCREST_SCHEMA], _has_unique_names) @@ -117,21 +116,22 @@ def setup(hass, config): """Set up the Amcrest IP Camera component.""" from amcrest import AmcrestCamera, AmcrestError - hass.data.setdefault(DATA_AMCREST, {}) - amcrest_cams = config[DOMAIN] + hass.data.setdefault(DATA_AMCREST, {'devices': {}, 'cameras': []}) + devices = config[DOMAIN] - for device in amcrest_cams: + for device in devices: name = device[CONF_NAME] username = device[CONF_USERNAME] password = device[CONF_PASSWORD] try: - camera = AmcrestCamera(device[CONF_HOST], - device[CONF_PORT], - username, - password).camera + api = AmcrestCamera(device[CONF_HOST], + device[CONF_PORT], + username, + password).camera # pylint: disable=pointless-statement - camera.current_time + # Test camera communications. + api.current_time except AmcrestError as ex: _LOGGER.error("Unable to connect to %s camera: %s", name, str(ex)) @@ -148,7 +148,7 @@ def setup(hass, config): binary_sensors = device.get(CONF_BINARY_SENSORS) sensors = device.get(CONF_SENSORS) switches = device.get(CONF_SWITCHES) - stream_source = STREAM_SOURCE_LIST[device[CONF_STREAM_SOURCE]] + stream_source = device[CONF_STREAM_SOURCE] # currently aiohttp only works with basic authentication # only valid for mjpeg streaming @@ -157,47 +157,97 @@ def setup(hass, config): else: authentication = None - hass.data[DATA_AMCREST][name] = AmcrestDevice( - camera, name, authentication, ffmpeg_arguments, stream_source, + hass.data[DATA_AMCREST]['devices'][name] = AmcrestDevice( + api, authentication, ffmpeg_arguments, stream_source, resolution) discovery.load_platform( - hass, 'camera', DOMAIN, { + hass, CAMERA, DOMAIN, { CONF_NAME: name, }, config) if binary_sensors: discovery.load_platform( - hass, 'binary_sensor', DOMAIN, { + hass, BINARY_SENSOR, DOMAIN, { CONF_NAME: name, CONF_BINARY_SENSORS: binary_sensors }, config) if sensors: discovery.load_platform( - hass, 'sensor', DOMAIN, { + hass, SENSOR, DOMAIN, { CONF_NAME: name, CONF_SENSORS: sensors, }, config) if switches: discovery.load_platform( - hass, 'switch', DOMAIN, { + hass, SWITCH, DOMAIN, { CONF_NAME: name, CONF_SWITCHES: switches }, config) - return len(hass.data[DATA_AMCREST]) >= 1 + if not hass.data[DATA_AMCREST]['devices']: + return False + + def have_permission(user, entity_id): + return not user or user.permissions.check_entity( + entity_id, POLICY_CONTROL) + + async def async_extract_from_service(call): + if call.context.user_id: + user = await hass.auth.async_get_user(call.context.user_id) + if user is None: + raise UnknownUser(context=call.context) + else: + user = None + + if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL: + # Return all entity_ids user has permission to control. + return [ + entity_id for entity_id in hass.data[DATA_AMCREST]['cameras'] + if have_permission(user, entity_id) + ] + + call_ids = await async_extract_entity_ids(hass, call) + entity_ids = [] + for entity_id in hass.data[DATA_AMCREST]['cameras']: + if entity_id not in call_ids: + continue + if not have_permission(user, entity_id): + raise Unauthorized( + context=call.context, + entity_id=entity_id, + permission=POLICY_CONTROL + ) + entity_ids.append(entity_id) + return entity_ids + + async def async_service_handler(call): + args = [] + for arg in CAMERA_SERVICES[call.service][2]: + args.append(call.data[arg]) + for entity_id in await async_extract_from_service(call): + async_dispatcher_send( + hass, + service_signal(call.service, entity_id), + *args + ) + + for service, params in CAMERA_SERVICES.items(): + hass.services.async_register( + DOMAIN, service, async_service_handler, params[0]) + + return True class AmcrestDevice: """Representation of a base Amcrest discovery device.""" - def __init__(self, camera, name, authentication, ffmpeg_arguments, + def __init__(self, api, authentication, ffmpeg_arguments, stream_source, resolution): """Initialize the entity.""" - self.device = camera - self.name = name + self.api = api self.authentication = authentication self.ffmpeg_arguments = ffmpeg_arguments self.stream_source = stream_source diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py index b591616a88d..0eb9e42e707 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -5,38 +5,39 @@ import logging from homeassistant.components.binary_sensor import ( BinarySensorDevice, DEVICE_CLASS_MOTION) from homeassistant.const import CONF_NAME, CONF_BINARY_SENSORS -from . import DATA_AMCREST, BINARY_SENSORS + +from .const import BINARY_SENSOR_SCAN_INTERVAL_SECS, DATA_AMCREST _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=5) +SCAN_INTERVAL = timedelta(seconds=BINARY_SENSOR_SCAN_INTERVAL_SECS) + +BINARY_SENSORS = { + 'motion_detected': 'Motion Detected' +} -async def async_setup_platform(hass, config, async_add_devices, +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up a binary sensor for an Amcrest IP Camera.""" if discovery_info is None: return - device_name = discovery_info[CONF_NAME] - binary_sensors = discovery_info[CONF_BINARY_SENSORS] - amcrest = hass.data[DATA_AMCREST][device_name] - - amcrest_binary_sensors = [] - for sensor_type in binary_sensors: - amcrest_binary_sensors.append( - AmcrestBinarySensor(amcrest.name, amcrest.device, sensor_type)) - - async_add_devices(amcrest_binary_sensors, True) + name = discovery_info[CONF_NAME] + device = hass.data[DATA_AMCREST]['devices'][name] + async_add_entities( + [AmcrestBinarySensor(name, device, sensor_type) + for sensor_type in discovery_info[CONF_BINARY_SENSORS]], + True) class AmcrestBinarySensor(BinarySensorDevice): """Binary sensor for Amcrest camera.""" - def __init__(self, name, camera, sensor_type): + def __init__(self, name, device, sensor_type): """Initialize entity.""" self._name = '{} {}'.format(name, BINARY_SENSORS[sensor_type]) - self._camera = camera + self._api = device.api self._sensor_type = sensor_type self._state = None @@ -62,7 +63,7 @@ class AmcrestBinarySensor(BinarySensorDevice): _LOGGER.debug('Pulling data from %s binary sensor', self._name) try: - self._state = self._camera.is_motion_detected + self._state = self._api.is_motion_detected except AmcrestError as error: _LOGGER.error( 'Could not update %s binary sensor due to error: %s', diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 07f5d403ba8..e646c11f2e9 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -2,18 +2,72 @@ import asyncio import logging +import voluptuous as vol + from homeassistant.components.camera import ( - Camera, SUPPORT_ON_OFF, SUPPORT_STREAM) + Camera, CAMERA_SERVICE_SCHEMA, SUPPORT_ON_OFF, SUPPORT_STREAM) from homeassistant.components.ffmpeg import DATA_FFMPEG -from homeassistant.const import CONF_NAME +from homeassistant.const import ( + CONF_NAME, STATE_ON, STATE_OFF) from homeassistant.helpers.aiohttp_client import ( async_aiohttp_proxy_stream, async_aiohttp_proxy_web, async_get_clientsession) +from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DATA_AMCREST, STREAM_SOURCE_LIST, TIMEOUT +from .const import CAMERA_WEB_SESSION_TIMEOUT, DATA_AMCREST +from .helpers import service_signal _LOGGER = logging.getLogger(__name__) +STREAM_SOURCE_LIST = [ + 'snapshot', + 'mjpeg', + 'rtsp', +] + +_SRV_EN_REC = 'enable_recording' +_SRV_DS_REC = 'disable_recording' +_SRV_EN_AUD = 'enable_audio' +_SRV_DS_AUD = 'disable_audio' +_SRV_EN_MOT_REC = 'enable_motion_recording' +_SRV_DS_MOT_REC = 'disable_motion_recording' +_SRV_GOTO = 'goto_preset' +_SRV_CBW = 'set_color_bw' +_SRV_TOUR_ON = 'start_tour' +_SRV_TOUR_OFF = 'stop_tour' + +_ATTR_PRESET = 'preset' +_ATTR_COLOR_BW = 'color_bw' + +_CBW_COLOR = 'color' +_CBW_AUTO = 'auto' +_CBW_BW = 'bw' +_CBW = [_CBW_COLOR, _CBW_AUTO, _CBW_BW] + +_SRV_GOTO_SCHEMA = CAMERA_SERVICE_SCHEMA.extend({ + vol.Required(_ATTR_PRESET): vol.All(vol.Coerce(int), vol.Range(min=1)), +}) +_SRV_CBW_SCHEMA = CAMERA_SERVICE_SCHEMA.extend({ + vol.Required(_ATTR_COLOR_BW): vol.In(_CBW), +}) + +CAMERA_SERVICES = { + _SRV_EN_REC: (CAMERA_SERVICE_SCHEMA, 'async_enable_recording', ()), + _SRV_DS_REC: (CAMERA_SERVICE_SCHEMA, 'async_disable_recording', ()), + _SRV_EN_AUD: (CAMERA_SERVICE_SCHEMA, 'async_enable_audio', ()), + _SRV_DS_AUD: (CAMERA_SERVICE_SCHEMA, 'async_disable_audio', ()), + _SRV_EN_MOT_REC: ( + CAMERA_SERVICE_SCHEMA, 'async_enable_motion_recording', ()), + _SRV_DS_MOT_REC: ( + CAMERA_SERVICE_SCHEMA, 'async_disable_motion_recording', ()), + _SRV_GOTO: (_SRV_GOTO_SCHEMA, 'async_goto_preset', (_ATTR_PRESET,)), + _SRV_CBW: (_SRV_CBW_SCHEMA, 'async_set_color_bw', (_ATTR_COLOR_BW,)), + _SRV_TOUR_ON: (CAMERA_SERVICE_SCHEMA, 'async_start_tour', ()), + _SRV_TOUR_OFF: (CAMERA_SERVICE_SCHEMA, 'async_stop_tour', ()), +} + +_BOOL_TO_STATE = {True: STATE_ON, False: STATE_OFF} + async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -21,28 +75,33 @@ async def async_setup_platform(hass, config, async_add_entities, if discovery_info is None: return - device_name = discovery_info[CONF_NAME] - amcrest = hass.data[DATA_AMCREST][device_name] - - async_add_entities([AmcrestCam(hass, amcrest)], True) + name = discovery_info[CONF_NAME] + device = hass.data[DATA_AMCREST]['devices'][name] + async_add_entities([ + AmcrestCam(name, device, hass.data[DATA_FFMPEG])], True) class AmcrestCam(Camera): """An implementation of an Amcrest IP camera.""" - def __init__(self, hass, amcrest): + def __init__(self, name, device, ffmpeg): """Initialize an Amcrest camera.""" - super(AmcrestCam, self).__init__() - self._name = amcrest.name - self._camera = amcrest.device - self._ffmpeg = hass.data[DATA_FFMPEG] - self._ffmpeg_arguments = amcrest.ffmpeg_arguments - self._stream_source = amcrest.stream_source - self._resolution = amcrest.resolution - self._token = self._auth = amcrest.authentication + super().__init__() + self._name = name + self._api = device.api + self._ffmpeg = ffmpeg + self._ffmpeg_arguments = device.ffmpeg_arguments + self._stream_source = device.stream_source + self._resolution = device.resolution + self._token = self._auth = device.authentication self._is_recording = False + self._motion_detection_enabled = None self._model = None + self._audio_enabled = None + self._motion_recording_enabled = None + self._color_bw = None self._snapshot_lock = asyncio.Lock() + self._unsub_dispatcher = [] async def async_camera_image(self): """Return a still image response from the camera.""" @@ -56,7 +115,7 @@ class AmcrestCam(Camera): try: # Send the request to snap a picture and return raw jpg data response = await self.hass.async_add_executor_job( - self._camera.snapshot, self._resolution) + self._api.snapshot, self._resolution) return response.data except AmcrestError as error: _LOGGER.error( @@ -67,15 +126,16 @@ class AmcrestCam(Camera): async 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']: + if self._stream_source == 'snapshot': return await super().handle_async_mjpeg_stream(request) - if self._stream_source == STREAM_SOURCE_LIST['mjpeg']: + if self._stream_source == 'mjpeg': # stream an MJPEG image stream directly from the camera websession = async_get_clientsession(self.hass) - streaming_url = self._camera.mjpeg_url(typeno=self._resolution) + streaming_url = self._api.mjpeg_url(typeno=self._resolution) stream_coro = websession.get( - streaming_url, auth=self._token, timeout=TIMEOUT) + streaming_url, auth=self._token, + timeout=CAMERA_WEB_SESSION_TIMEOUT) return await async_aiohttp_proxy_web( self.hass, request, stream_coro) @@ -83,7 +143,7 @@ class AmcrestCam(Camera): # streaming via ffmpeg from haffmpeg.camera import CameraMjpeg - streaming_url = self._camera.rtsp_url(typeno=self._resolution) + streaming_url = self._api.rtsp_url(typeno=self._resolution) stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) await stream.open_camera( streaming_url, extra_cmd=self._ffmpeg_arguments) @@ -103,6 +163,19 @@ class AmcrestCam(Camera): """Return the name of this camera.""" return self._name + @property + def device_state_attributes(self): + """Return the Amcrest-specific camera state attributes.""" + attr = {} + if self._audio_enabled is not None: + attr['audio'] = _BOOL_TO_STATE.get(self._audio_enabled) + if self._motion_recording_enabled is not None: + attr['motion_recording'] = _BOOL_TO_STATE.get( + self._motion_recording_enabled) + if self._color_bw is not None: + attr[_ATTR_COLOR_BW] = self._color_bw + return attr + @property def supported_features(self): """Return supported features.""" @@ -120,6 +193,11 @@ class AmcrestCam(Camera): """Return the camera brand.""" return 'Amcrest' + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return self._motion_detection_enabled + @property def model(self): """Return the camera model.""" @@ -128,7 +206,7 @@ class AmcrestCam(Camera): @property def stream_source(self): """Return the source of the stream.""" - return self._camera.rtsp_url(typeno=self._resolution) + return self._api.rtsp_url(typeno=self._resolution) @property def is_on(self): @@ -137,6 +215,21 @@ class AmcrestCam(Camera): # Other Entity method overrides + async def async_added_to_hass(self): + """Subscribe to signals and add camera to list.""" + for service, params in CAMERA_SERVICES.items(): + self._unsub_dispatcher.append(async_dispatcher_connect( + self.hass, + service_signal(service, self.entity_id), + getattr(self, params[1]))) + self.hass.data[DATA_AMCREST]['cameras'].append(self.entity_id) + + async def async_will_remove_from_hass(self): + """Remove camera from list and disconnect from signals.""" + self.hass.data[DATA_AMCREST]['cameras'].remove(self.entity_id) + for unsub_dispatcher in self._unsub_dispatcher: + unsub_dispatcher() + def update(self): """Update entity status.""" from amcrest import AmcrestError @@ -144,15 +237,21 @@ class AmcrestCam(Camera): _LOGGER.debug('Pulling data from %s camera', self.name) if self._model is None: try: - self._model = self._camera.device_type.split('=')[-1].strip() + self._model = self._api.device_type.split('=')[-1].strip() except AmcrestError as error: _LOGGER.error( 'Could not get %s camera model due to error: %s', self.name, error) self._model = '' try: - self.is_streaming = self._camera.video_enabled - self._is_recording = self._camera.record_mode == 'Manual' + self.is_streaming = self._api.video_enabled + self._is_recording = self._api.record_mode == 'Manual' + self._motion_detection_enabled = ( + self._api.is_motion_detector_on()) + self._audio_enabled = self._api.audio_enabled + self._motion_recording_enabled = ( + self._api.is_record_on_motion_detection()) + self._color_bw = _CBW[self._api.day_night_color] except AmcrestError as error: _LOGGER.error( 'Could not get %s camera attributes due to error: %s', @@ -168,14 +267,71 @@ class AmcrestCam(Camera): """Turn on camera.""" self._enable_video_stream(True) - # Utility methods + def enable_motion_detection(self): + """Enable motion detection in the camera.""" + self._enable_motion_detection(True) + + def disable_motion_detection(self): + """Disable motion detection in camera.""" + self._enable_motion_detection(False) + + # Additional Amcrest Camera service methods + + async def async_enable_recording(self): + """Call the job and enable recording.""" + await self.hass.async_add_executor_job(self._enable_recording, True) + + async def async_disable_recording(self): + """Call the job and disable recording.""" + await self.hass.async_add_executor_job(self._enable_recording, False) + + async def async_enable_audio(self): + """Call the job and enable audio.""" + await self.hass.async_add_executor_job(self._enable_audio, True) + + async def async_disable_audio(self): + """Call the job and disable audio.""" + await self.hass.async_add_executor_job(self._enable_audio, False) + + async def async_enable_motion_recording(self): + """Call the job and enable motion recording.""" + await self.hass.async_add_executor_job(self._enable_motion_recording, + True) + + async def async_disable_motion_recording(self): + """Call the job and disable motion recording.""" + await self.hass.async_add_executor_job(self._enable_motion_recording, + False) + + async def async_goto_preset(self, preset): + """Call the job and move camera to preset position.""" + await self.hass.async_add_executor_job(self._goto_preset, preset) + + async def async_set_color_bw(self, color_bw): + """Call the job and set camera color mode.""" + await self.hass.async_add_executor_job(self._set_color_bw, color_bw) + + async def async_start_tour(self): + """Call the job and start camera tour.""" + await self.hass.async_add_executor_job(self._start_tour, True) + + async def async_stop_tour(self): + """Call the job and stop camera tour.""" + await self.hass.async_add_executor_job(self._start_tour, False) + + # Methods to send commands to Amcrest camera and handle errors def _enable_video_stream(self, enable): """Enable or disable camera video stream.""" from amcrest import AmcrestError + # Given the way the camera's state is determined by + # is_streaming and is_recording, we can't leave + # recording on if video stream is being turned off. + if self.is_recording and not enable: + self._enable_recording(False) try: - self._camera.video_enabled = enable + self._api.video_enabled = enable except AmcrestError as error: _LOGGER.error( 'Could not %s %s camera video stream due to error: %s', @@ -183,3 +339,103 @@ class AmcrestCam(Camera): else: self.is_streaming = enable self.schedule_update_ha_state() + + def _enable_recording(self, enable): + """Turn recording on or off.""" + from amcrest import AmcrestError + + # Given the way the camera's state is determined by + # is_streaming and is_recording, we can't leave + # video stream off if recording is being turned on. + if not self.is_streaming and enable: + self._enable_video_stream(True) + rec_mode = {'Automatic': 0, 'Manual': 1} + try: + self._api.record_mode = rec_mode[ + 'Manual' if enable else 'Automatic'] + except AmcrestError as error: + _LOGGER.error( + 'Could not %s %s camera recording due to error: %s', + 'enable' if enable else 'disable', self.name, error) + else: + self._is_recording = enable + self.schedule_update_ha_state() + + def _enable_motion_detection(self, enable): + """Enable or disable motion detection.""" + from amcrest import AmcrestError + + try: + self._api.motion_detection = str(enable).lower() + except AmcrestError as error: + _LOGGER.error( + 'Could not %s %s camera motion detection due to error: %s', + 'enable' if enable else 'disable', self.name, error) + else: + self._motion_detection_enabled = enable + self.schedule_update_ha_state() + + def _enable_audio(self, enable): + """Enable or disable audio stream.""" + from amcrest import AmcrestError + + try: + self._api.audio_enabled = enable + except AmcrestError as error: + _LOGGER.error( + 'Could not %s %s camera audio stream due to error: %s', + 'enable' if enable else 'disable', self.name, error) + else: + self._audio_enabled = enable + self.schedule_update_ha_state() + + def _enable_motion_recording(self, enable): + """Enable or disable motion recording.""" + from amcrest import AmcrestError + + try: + self._api.motion_recording = str(enable).lower() + except AmcrestError as error: + _LOGGER.error( + 'Could not %s %s camera motion recording due to error: %s', + 'enable' if enable else 'disable', self.name, error) + else: + self._motion_recording_enabled = enable + self.schedule_update_ha_state() + + def _goto_preset(self, preset): + """Move camera position and zoom to preset.""" + from amcrest import AmcrestError + + try: + self._api.go_to_preset( + action='start', preset_point_number=preset) + except AmcrestError as error: + _LOGGER.error( + 'Could not move %s camera to preset %i due to error: %s', + self.name, preset, error) + + def _set_color_bw(self, cbw): + """Set camera color mode.""" + from amcrest import AmcrestError + + try: + self._api.day_night_color = _CBW.index(cbw) + except AmcrestError as error: + _LOGGER.error( + 'Could not set %s camera color mode to %s due to error: %s', + self.name, cbw, error) + else: + self._color_bw = cbw + self.schedule_update_ha_state() + + def _start_tour(self, start): + """Start camera tour.""" + from amcrest import AmcrestError + + try: + self._api.tour(start=start) + except AmcrestError as error: + _LOGGER.error( + 'Could not %s %s camera tour due to error: %s', + 'start' if start else 'stop', self.name, error) diff --git a/homeassistant/components/amcrest/const.py b/homeassistant/components/amcrest/const.py new file mode 100644 index 00000000000..a0230937e95 --- /dev/null +++ b/homeassistant/components/amcrest/const.py @@ -0,0 +1,7 @@ +"""Constants for amcrest component.""" +DOMAIN = 'amcrest' +DATA_AMCREST = DOMAIN + +BINARY_SENSOR_SCAN_INTERVAL_SECS = 5 +CAMERA_WEB_SESSION_TIMEOUT = 10 +SENSOR_SCAN_INTERVAL_SECS = 10 diff --git a/homeassistant/components/amcrest/helpers.py b/homeassistant/components/amcrest/helpers.py new file mode 100644 index 00000000000..270c969a6cc --- /dev/null +++ b/homeassistant/components/amcrest/helpers.py @@ -0,0 +1,10 @@ +"""Helpers for amcrest component.""" +from .const import DOMAIN + + +def service_signal(service, entity_id=None): + """Encode service and entity_id into signal.""" + signal = '{}_{}'.format(DOMAIN, service) + if entity_id: + signal += '_{}'.format(entity_id.replace('.', '_')) + return signal diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py index 56cb021052e..4d2cd88c5ae 100644 --- a/homeassistant/components/amcrest/sensor.py +++ b/homeassistant/components/amcrest/sensor.py @@ -5,11 +5,19 @@ import logging from homeassistant.const import CONF_NAME, CONF_SENSORS from homeassistant.helpers.entity import Entity -from . import DATA_AMCREST, SENSORS +from .const import DATA_AMCREST, SENSOR_SCAN_INTERVAL_SECS _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=10) +SCAN_INTERVAL = timedelta(seconds=SENSOR_SCAN_INTERVAL_SECS) + +# Sensor types are defined like: Name, units, icon +SENSOR_MOTION_DETECTOR = 'motion_detector' +SENSORS = { + SENSOR_MOTION_DETECTOR: ['Motion Detected', None, 'mdi:run'], + 'sdcard': ['SD Used', '%', 'mdi:sd'], + 'ptz_preset': ['PTZ Preset', None, 'mdi:camera-iris'], +} async def async_setup_platform( @@ -18,30 +26,26 @@ async def async_setup_platform( if discovery_info is None: return - device_name = discovery_info[CONF_NAME] - sensors = discovery_info[CONF_SENSORS] - amcrest = hass.data[DATA_AMCREST][device_name] - - amcrest_sensors = [] - for sensor_type in sensors: - amcrest_sensors.append( - AmcrestSensor(amcrest.name, amcrest.device, sensor_type)) - - async_add_entities(amcrest_sensors, True) + name = discovery_info[CONF_NAME] + device = hass.data[DATA_AMCREST]['devices'][name] + async_add_entities( + [AmcrestSensor(name, device, sensor_type) + for sensor_type in discovery_info[CONF_SENSORS]], + True) class AmcrestSensor(Entity): """A sensor implementation for Amcrest IP camera.""" - def __init__(self, name, camera, sensor_type): + def __init__(self, name, device, sensor_type): """Initialize a sensor for Amcrest camera.""" - self._attrs = {} - self._camera = camera + self._name = '{} {}'.format(name, SENSORS[sensor_type][0]) + self._api = device.api self._sensor_type = sensor_type - self._name = '{0}_{1}'.format( - name, SENSORS.get(self._sensor_type)[0]) - self._icon = 'mdi:{}'.format(SENSORS.get(self._sensor_type)[2]) self._state = None + self._attrs = {} + self._unit_of_measurement = SENSORS[sensor_type][1] + self._icon = SENSORS[sensor_type][2] @property def name(self): @@ -66,22 +70,22 @@ class AmcrestSensor(Entity): @property def unit_of_measurement(self): """Return the units of measurement.""" - return SENSORS.get(self._sensor_type)[1] + return self._unit_of_measurement def update(self): """Get the latest data and updates the state.""" _LOGGER.debug("Pulling data from %s sensor.", self._name) if self._sensor_type == 'motion_detector': - self._state = self._camera.is_motion_detected - self._attrs['Record Mode'] = self._camera.record_mode + self._state = self._api.is_motion_detected + self._attrs['Record Mode'] = self._api.record_mode elif self._sensor_type == 'ptz_preset': - self._state = self._camera.ptz_presets_count + self._state = self._api.ptz_presets_count elif self._sensor_type == 'sdcard': - sd_used = self._camera.storage_used - sd_total = self._camera.storage_total + sd_used = self._api.storage_used + sd_total = self._api.storage_total self._attrs['Total'] = '{0} {1}'.format(*sd_total) self._attrs['Used'] = '{0} {1}'.format(*sd_used) - self._state = self._camera.storage_used_percent + self._state = self._api.storage_used_percent diff --git a/homeassistant/components/amcrest/services.yaml b/homeassistant/components/amcrest/services.yaml new file mode 100644 index 00000000000..d6e7a02a4f9 --- /dev/null +++ b/homeassistant/components/amcrest/services.yaml @@ -0,0 +1,75 @@ +enable_recording: + description: Enable continuous recording to camera storage. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + +disable_recording: + description: Disable continuous recording to camera storage. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + +enable_audio: + description: Enable audio stream. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + +disable_audio: + description: Disable audio stream. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + +enable_motion_recording: + description: Enable recording a clip to camera storage when motion is detected. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + +disable_motion_recording: + description: Disable recording a clip to camera storage when motion is detected. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + +goto_preset: + description: Move camera to PTZ preset. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + preset: + description: Preset number, starting from 1. + example: 1 + +set_color_bw: + description: Set camera color mode. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + color_bw: + description: Color mode, one of 'auto', 'color' or 'bw'. + example: auto + +start_tour: + description: Start camera's PTZ tour function. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' + +stop_tour: + description: Stop camera's PTZ tour function. + fields: + entity_id: + description: "Name(s) of the cameras, or 'all' for all cameras." + example: 'camera.house_front' diff --git a/homeassistant/components/amcrest/switch.py b/homeassistant/components/amcrest/switch.py index 90f750d1797..5989d4daf1e 100644 --- a/homeassistant/components/amcrest/switch.py +++ b/homeassistant/components/amcrest/switch.py @@ -1,13 +1,19 @@ """Support for toggling Amcrest IP camera settings.""" import logging -from homeassistant.const import CONF_NAME, CONF_SWITCHES, STATE_OFF, STATE_ON +from homeassistant.const import CONF_NAME, CONF_SWITCHES from homeassistant.helpers.entity import ToggleEntity -from . import DATA_AMCREST, SWITCHES +from .const import DATA_AMCREST _LOGGER = logging.getLogger(__name__) +# Switch types are defined like: Name, icon +SWITCHES = { + 'motion_detection': ['Motion Detection', 'mdi:run-fast'], + 'motion_recording': ['Motion Recording', 'mdi:record-rec'] +} + async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): @@ -16,67 +22,58 @@ async def async_setup_platform( return name = discovery_info[CONF_NAME] - switches = discovery_info[CONF_SWITCHES] - camera = hass.data[DATA_AMCREST][name].device - - all_switches = [] - - for setting in switches: - all_switches.append(AmcrestSwitch(setting, camera, name)) - - async_add_entities(all_switches, True) + device = hass.data[DATA_AMCREST]['devices'][name] + async_add_entities( + [AmcrestSwitch(name, device, setting) + for setting in discovery_info[CONF_SWITCHES]], + True) class AmcrestSwitch(ToggleEntity): """Representation of an Amcrest IP camera switch.""" - def __init__(self, setting, camera, name): + def __init__(self, name, device, setting): """Initialize the Amcrest switch.""" + self._name = '{} {}'.format(name, SWITCHES[setting][0]) + self._api = device.api self._setting = setting - self._camera = camera - self._name = '{} {}'.format(SWITCHES[setting][0], name) + self._state = False self._icon = SWITCHES[setting][1] - self._state = None @property def name(self): """Return the name of the switch if any.""" return self._name - @property - def state(self): - """Return the state of the switch.""" - return self._state - @property def is_on(self): """Return true if switch is on.""" - return self._state == STATE_ON + return self._state def turn_on(self, **kwargs): """Turn setting on.""" if self._setting == 'motion_detection': - self._camera.motion_detection = 'true' + self._api.motion_detection = 'true' elif self._setting == 'motion_recording': - self._camera.motion_recording = 'true' + self._api.motion_recording = 'true' def turn_off(self, **kwargs): """Turn setting off.""" if self._setting == 'motion_detection': - self._camera.motion_detection = 'false' + self._api.motion_detection = 'false' elif self._setting == 'motion_recording': - self._camera.motion_recording = 'false' + self._api.motion_recording = 'false' def update(self): """Update setting state.""" _LOGGER.debug("Polling state for setting: %s ", self._name) if self._setting == 'motion_detection': - detection = self._camera.is_motion_detector_on() + detection = self._api.is_motion_detector_on() elif self._setting == 'motion_recording': - detection = self._camera.is_record_on_motion_detection() + detection = self._api.is_record_on_motion_detection() - self._state = STATE_ON if detection else STATE_OFF + self._state = detection @property def icon(self):