Skip to content

Commit

Permalink
Add turn_on/off service to camera (#15051)
Browse files Browse the repository at this point in the history
* Add turn_on/off to camera

* Add turn_on/off supported features to camera.

Add turn_on/off service implementation to camera, add turn_on/off
 supported features and services to Demo camera.

* Add camera supported_features tests

* Resolve code review comment

* Fix unit test

* Use async_add_executor_job

* Address review comment, change DemoCamera to local push

* Rewrite tests/components/camera/test_demo

* raise HTTPError instead return response
  • Loading branch information
awarecan authored Jul 24, 2018
1 parent 2eb125e commit 45a7ca6
Show file tree
Hide file tree
Showing 4 changed files with 216 additions and 18 deletions.
87 changes: 84 additions & 3 deletions homeassistant/components/camera/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}'

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -393,15 +471,18 @@ 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)

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):
Expand Down
48 changes: 38 additions & 10 deletions homeassistant/components/camera/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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()

Expand All @@ -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):
Expand All @@ -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()
14 changes: 14 additions & 0 deletions homeassistant/components/camera/services.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
85 changes: 80 additions & 5 deletions tests/components/camera/test_demo.py
Original file line number Diff line number Diff line change
@@ -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'
}
Expand All @@ -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')
Expand Down

0 comments on commit 45a7ca6

Please sign in to comment.