diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 3c252adb7ea3d7..736bcec1e9ca39 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -19,7 +19,8 @@ import voluptuous as vol from homeassistant.core import callback -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, \ + SERVICE_TURN_ON from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass from homeassistant.helpers.entity import Entity @@ -47,6 +48,9 @@ STATE_STREAMING = 'streaming' STATE_IDLE = 'idle' +# Bitfield of features supported by the camera entity +SUPPORT_ON_OFF = 1 + DEFAULT_CONTENT_TYPE = 'image/jpeg' ENTITY_IMAGE_URL = '/api/camera_proxy/{0}?token={1}' @@ -79,6 +83,35 @@ class Image: content = attr.ib(type=bytes) +@bind_hass +def turn_off(hass, entity_id=None): + """Turn off camera.""" + hass.add_job(async_turn_off, hass, entity_id) + + +@bind_hass +async def async_turn_off(hass, entity_id=None): + """Turn off camera.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + await hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data) + + +@bind_hass +def turn_on(hass, entity_id=None): + """Turn on camera.""" + hass.add_job(async_turn_on, hass, entity_id) + + +@bind_hass +async def async_turn_on(hass, entity_id=None): + """Turn on camera, and set operation mode.""" + data = {} + if entity_id is not None: + data[ATTR_ENTITY_ID] = entity_id + + await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data) + + @bind_hass def enable_motion_detection(hass, entity_id=None): """Enable Motion Detection.""" @@ -119,6 +152,9 @@ async def async_get_image(hass, entity_id, timeout=10): if camera is None: raise HomeAssistantError('Camera not found') + if not camera.is_on: + raise HomeAssistantError('Camera is off') + with suppress(asyncio.CancelledError, asyncio.TimeoutError): with async_timeout.timeout(timeout, loop=hass.loop): image = await camera.async_camera_image() @@ -163,6 +199,12 @@ async def async_handle_camera_service(service): await camera.async_enable_motion_detection() elif service.service == SERVICE_DISABLE_MOTION: await camera.async_disable_motion_detection() + elif service.service == SERVICE_TURN_OFF and \ + camera.supported_features & SUPPORT_ON_OFF: + await camera.async_turn_off() + elif service.service == SERVICE_TURN_ON and \ + camera.supported_features & SUPPORT_ON_OFF: + await camera.async_turn_on() if not camera.should_poll: continue @@ -200,6 +242,12 @@ def _write_image(to_file, image_data): except OSError as err: _LOGGER.error("Can't write image to file: %s", err) + hass.services.async_register( + DOMAIN, SERVICE_TURN_OFF, async_handle_camera_service, + schema=CAMERA_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_TURN_ON, async_handle_camera_service, + schema=CAMERA_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_ENABLE_MOTION, async_handle_camera_service, schema=CAMERA_SERVICE_SCHEMA) @@ -243,6 +291,11 @@ def entity_picture(self): """Return a link to the camera feed as entity picture.""" return ENTITY_IMAGE_URL.format(self.entity_id, self.access_tokens[-1]) + @property + def supported_features(self): + """Flag supported features.""" + return 0 + @property def is_recording(self): """Return true if the device is recording.""" @@ -337,10 +390,34 @@ def state(self): return STATE_STREAMING return STATE_IDLE + @property + def is_on(self): + """Return true if on.""" + return True + + def turn_off(self): + """Turn off camera.""" + raise NotImplementedError() + + @callback + def async_turn_off(self): + """Turn off camera.""" + return self.hass.async_add_job(self.turn_off) + + def turn_on(self): + """Turn off camera.""" + raise NotImplementedError() + + @callback + def async_turn_on(self): + """Turn off camera.""" + return self.hass.async_add_job(self.turn_on) + def enable_motion_detection(self): """Enable motion detection in the camera.""" raise NotImplementedError() + @callback def async_enable_motion_detection(self): """Call the job and enable motion detection.""" return self.hass.async_add_job(self.enable_motion_detection) @@ -349,6 +426,7 @@ def disable_motion_detection(self): """Disable motion detection in camera.""" raise NotImplementedError() + @callback def async_disable_motion_detection(self): """Call the job and disable motion detection.""" return self.hass.async_add_job(self.disable_motion_detection) @@ -393,8 +471,7 @@ async def get(self, request, entity_id): camera = self.component.get_entity(entity_id) if camera is None: - status = 404 if request[KEY_AUTHENTICATED] else 401 - return web.Response(status=status) + raise web.HTTPNotFound() authenticated = (request[KEY_AUTHENTICATED] or request.query.get('token') in camera.access_tokens) @@ -402,6 +479,10 @@ async def get(self, request, entity_id): if not authenticated: raise web.HTTPUnauthorized() + if not camera.is_on: + _LOGGER.debug('Camera is off.') + raise web.HTTPServiceUnavailable() + return await self.handle(request, camera) async def handle(self, request, camera): diff --git a/homeassistant/components/camera/demo.py b/homeassistant/components/camera/demo.py index 3c1477d1828658..0e77e6e95adccb 100644 --- a/homeassistant/components/camera/demo.py +++ b/homeassistant/components/camera/demo.py @@ -4,10 +4,10 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ -import os import logging -import homeassistant.util.dt as dt_util -from homeassistant.components.camera import Camera +import os + +from homeassistant.components.camera import Camera, SUPPORT_ON_OFF _LOGGER = logging.getLogger(__name__) @@ -16,26 +16,29 @@ async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Demo camera platform.""" async_add_devices([ - DemoCamera(hass, config, 'Demo camera') + DemoCamera('Demo camera') ]) class DemoCamera(Camera): """The representation of a Demo camera.""" - def __init__(self, hass, config, name): + def __init__(self, name): """Initialize demo camera component.""" super().__init__() - self._parent = hass self._name = name self._motion_status = False + self.is_streaming = True + self._images_index = 0 def camera_image(self): """Return a faked still image response.""" - now = dt_util.utcnow() + self._images_index = (self._images_index + 1) % 4 image_path = os.path.join( - os.path.dirname(__file__), 'demo_{}.jpg'.format(now.second % 4)) + os.path.dirname(__file__), + 'demo_{}.jpg'.format(self._images_index)) + _LOGGER.debug('Loading camera_image: %s', image_path) with open(image_path, 'rb') as file: return file.read() @@ -46,8 +49,21 @@ def name(self): @property def should_poll(self): - """Camera should poll periodically.""" - return True + """Demo camera doesn't need poll. + + Need explicitly call schedule_update_ha_state() after state changed. + """ + return False + + @property + def supported_features(self): + """Camera support turn on/off features.""" + return SUPPORT_ON_OFF + + @property + def is_on(self): + """Whether camera is on (streaming).""" + return self.is_streaming @property def motion_detection_enabled(self): @@ -57,7 +73,19 @@ def motion_detection_enabled(self): def enable_motion_detection(self): """Enable the Motion detection in base station (Arm).""" self._motion_status = True + self.schedule_update_ha_state() def disable_motion_detection(self): """Disable the motion detection in base station (Disarm).""" self._motion_status = False + self.schedule_update_ha_state() + + def turn_off(self): + """Turn off camera.""" + self.is_streaming = False + self.schedule_update_ha_state() + + def turn_on(self): + """Turn on camera.""" + self.is_streaming = True + self.schedule_update_ha_state() diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml index 544fd0e6b8a4d5..b977fcd5c52f89 100644 --- a/homeassistant/components/camera/services.yaml +++ b/homeassistant/components/camera/services.yaml @@ -1,5 +1,19 @@ # Describes the format for available camera services +turn_off: + description: Turn off camera. + fields: + entity_id: + description: Entity id. + example: 'camera.living_room' + +turn_on: + description: Turn on camera. + fields: + entity_id: + description: Entity id. + example: 'camera.living_room' + enable_motion_detection: description: Enable the motion detection in a camera. fields: diff --git a/tests/components/camera/test_demo.py b/tests/components/camera/test_demo.py index 51e04fca351789..b901b723c0be0b 100644 --- a/tests/components/camera/test_demo.py +++ b/tests/components/camera/test_demo.py @@ -1,14 +1,89 @@ """The tests for local file camera component.""" -import asyncio +from unittest.mock import mock_open, patch, PropertyMock + +import pytest + from homeassistant.components import camera +from homeassistant.components.camera import STATE_STREAMING, STATE_IDLE +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -@asyncio.coroutine -def test_motion_detection(hass): +@pytest.fixture +def demo_camera(hass): + """Initialize a demo camera platform.""" + hass.loop.run_until_complete(async_setup_component(hass, 'camera', { + camera.DOMAIN: { + 'platform': 'demo' + } + })) + return hass.data['camera'].get_entity('camera.demo_camera') + + +async def test_init_state_is_streaming(hass, demo_camera): + """Demo camera initialize as streaming.""" + assert demo_camera.state == STATE_STREAMING + + mock_on_img = mock_open(read_data=b'ON') + with patch('homeassistant.components.camera.demo.open', mock_on_img, + create=True): + image = await camera.async_get_image(hass, demo_camera.entity_id) + assert mock_on_img.called + assert mock_on_img.call_args_list[0][0][0][-6:] \ + in ['_0.jpg', '_1.jpg', '_2.jpg', '_3.jpg'] + assert image.content == b'ON' + + +async def test_turn_on_state_back_to_streaming(hass, demo_camera): + """After turn on state back to streaming.""" + assert demo_camera.state == STATE_STREAMING + await camera.async_turn_off(hass, demo_camera.entity_id) + await hass.async_block_till_done() + + assert demo_camera.state == STATE_IDLE + + await camera.async_turn_on(hass, demo_camera.entity_id) + await hass.async_block_till_done() + + assert demo_camera.state == STATE_STREAMING + + +async def test_turn_off_image(hass, demo_camera): + """After turn off, Demo camera raise error.""" + await camera.async_turn_off(hass, demo_camera.entity_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError) as error: + await camera.async_get_image(hass, demo_camera.entity_id) + assert error.args[0] == 'Camera is off' + + +async def test_turn_off_invalid_camera(hass, demo_camera): + """Turn off non-exist camera should quietly fail.""" + assert demo_camera.state == STATE_STREAMING + await camera.async_turn_off(hass, 'camera.invalid_camera') + await hass.async_block_till_done() + + assert demo_camera.state == STATE_STREAMING + + +async def test_turn_off_unsupport_camera(hass, demo_camera): + """Turn off unsupported camera should quietly fail.""" + assert demo_camera.state == STATE_STREAMING + with patch('homeassistant.components.camera.demo.DemoCamera' + '.supported_features', new_callable=PropertyMock) as m: + m.return_value = 0 + + await camera.async_turn_off(hass, demo_camera.entity_id) + await hass.async_block_till_done() + + assert demo_camera.state == STATE_STREAMING + + +async def test_motion_detection(hass): """Test motion detection services.""" # Setup platform - yield from async_setup_component(hass, 'camera', { + await async_setup_component(hass, 'camera', { 'camera': { 'platform': 'demo' } @@ -20,7 +95,7 @@ def test_motion_detection(hass): # Call service to turn on motion detection camera.enable_motion_detection(hass, 'camera.demo_camera') - yield from hass.async_block_till_done() + await hass.async_block_till_done() # Check if state has been updated. state = hass.states.get('camera.demo_camera')