Add turn_on/off service to camera (#15051)

* 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
This commit is contained in:
Jason Hu 2018-07-24 10:13:26 -07:00 committed by GitHub
parent 2eb125e90e
commit 45a7ca62ae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 216 additions and 18 deletions

View file

@ -19,7 +19,8 @@ import async_timeout
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_RECORDING = 'recording'
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_setup(hass, config):
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 @@ async def async_setup(hass, config):
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 @@ class Camera(Entity):
"""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 @@ class Camera(Entity):
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 @@ class Camera(Entity):
"""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 @@ class CameraView(HomeAssistantView):
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 @@ class CameraView(HomeAssistantView):
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):

View file

@ -4,10 +4,10 @@ Demo camera platform that has a fake camera.
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 @@ class DemoCamera(Camera):
@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 @@ class DemoCamera(Camera):
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()

View file

@ -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:

View file

@ -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')