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:
parent
2eb125e90e
commit
45a7ca62ae
4 changed files with 216 additions and 18 deletions
|
@ -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):
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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')
|
||||
|
|
Loading…
Add table
Reference in a new issue