mirror of
https://github.com/home-assistant/core.git
synced 2025-07-13 08:17:08 +00:00
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
@ -19,7 +19,8 @@ import async_timeout
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.core import callback
|
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.exceptions import HomeAssistantError
|
||||||
from homeassistant.loader import bind_hass
|
from homeassistant.loader import bind_hass
|
||||||
from homeassistant.helpers.entity import Entity
|
from homeassistant.helpers.entity import Entity
|
||||||
@ -47,6 +48,9 @@ STATE_RECORDING = 'recording'
|
|||||||
STATE_STREAMING = 'streaming'
|
STATE_STREAMING = 'streaming'
|
||||||
STATE_IDLE = 'idle'
|
STATE_IDLE = 'idle'
|
||||||
|
|
||||||
|
# Bitfield of features supported by the camera entity
|
||||||
|
SUPPORT_ON_OFF = 1
|
||||||
|
|
||||||
DEFAULT_CONTENT_TYPE = 'image/jpeg'
|
DEFAULT_CONTENT_TYPE = 'image/jpeg'
|
||||||
ENTITY_IMAGE_URL = '/api/camera_proxy/{0}?token={1}'
|
ENTITY_IMAGE_URL = '/api/camera_proxy/{0}?token={1}'
|
||||||
|
|
||||||
@ -79,6 +83,35 @@ class Image:
|
|||||||
content = attr.ib(type=bytes)
|
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
|
@bind_hass
|
||||||
def enable_motion_detection(hass, entity_id=None):
|
def enable_motion_detection(hass, entity_id=None):
|
||||||
"""Enable Motion Detection."""
|
"""Enable Motion Detection."""
|
||||||
@ -119,6 +152,9 @@ async def async_get_image(hass, entity_id, timeout=10):
|
|||||||
if camera is None:
|
if camera is None:
|
||||||
raise HomeAssistantError('Camera not found')
|
raise HomeAssistantError('Camera not found')
|
||||||
|
|
||||||
|
if not camera.is_on:
|
||||||
|
raise HomeAssistantError('Camera is off')
|
||||||
|
|
||||||
with suppress(asyncio.CancelledError, asyncio.TimeoutError):
|
with suppress(asyncio.CancelledError, asyncio.TimeoutError):
|
||||||
with async_timeout.timeout(timeout, loop=hass.loop):
|
with async_timeout.timeout(timeout, loop=hass.loop):
|
||||||
image = await camera.async_camera_image()
|
image = await camera.async_camera_image()
|
||||||
@ -163,6 +199,12 @@ async def async_setup(hass, config):
|
|||||||
await camera.async_enable_motion_detection()
|
await camera.async_enable_motion_detection()
|
||||||
elif service.service == SERVICE_DISABLE_MOTION:
|
elif service.service == SERVICE_DISABLE_MOTION:
|
||||||
await camera.async_disable_motion_detection()
|
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:
|
if not camera.should_poll:
|
||||||
continue
|
continue
|
||||||
@ -200,6 +242,12 @@ async def async_setup(hass, config):
|
|||||||
except OSError as err:
|
except OSError as err:
|
||||||
_LOGGER.error("Can't write image to file: %s", 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(
|
hass.services.async_register(
|
||||||
DOMAIN, SERVICE_ENABLE_MOTION, async_handle_camera_service,
|
DOMAIN, SERVICE_ENABLE_MOTION, async_handle_camera_service,
|
||||||
schema=CAMERA_SERVICE_SCHEMA)
|
schema=CAMERA_SERVICE_SCHEMA)
|
||||||
@ -243,6 +291,11 @@ class Camera(Entity):
|
|||||||
"""Return a link to the camera feed as entity picture."""
|
"""Return a link to the camera feed as entity picture."""
|
||||||
return ENTITY_IMAGE_URL.format(self.entity_id, self.access_tokens[-1])
|
return ENTITY_IMAGE_URL.format(self.entity_id, self.access_tokens[-1])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_features(self):
|
||||||
|
"""Flag supported features."""
|
||||||
|
return 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_recording(self):
|
def is_recording(self):
|
||||||
"""Return true if the device is recording."""
|
"""Return true if the device is recording."""
|
||||||
@ -337,10 +390,34 @@ class Camera(Entity):
|
|||||||
return STATE_STREAMING
|
return STATE_STREAMING
|
||||||
return STATE_IDLE
|
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):
|
def enable_motion_detection(self):
|
||||||
"""Enable motion detection in the camera."""
|
"""Enable motion detection in the camera."""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@callback
|
||||||
def async_enable_motion_detection(self):
|
def async_enable_motion_detection(self):
|
||||||
"""Call the job and enable motion detection."""
|
"""Call the job and enable motion detection."""
|
||||||
return self.hass.async_add_job(self.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."""
|
"""Disable motion detection in camera."""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@callback
|
||||||
def async_disable_motion_detection(self):
|
def async_disable_motion_detection(self):
|
||||||
"""Call the job and disable motion detection."""
|
"""Call the job and disable motion detection."""
|
||||||
return self.hass.async_add_job(self.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)
|
camera = self.component.get_entity(entity_id)
|
||||||
|
|
||||||
if camera is None:
|
if camera is None:
|
||||||
status = 404 if request[KEY_AUTHENTICATED] else 401
|
raise web.HTTPNotFound()
|
||||||
return web.Response(status=status)
|
|
||||||
|
|
||||||
authenticated = (request[KEY_AUTHENTICATED] or
|
authenticated = (request[KEY_AUTHENTICATED] or
|
||||||
request.query.get('token') in camera.access_tokens)
|
request.query.get('token') in camera.access_tokens)
|
||||||
@ -402,6 +479,10 @@ class CameraView(HomeAssistantView):
|
|||||||
if not authenticated:
|
if not authenticated:
|
||||||
raise web.HTTPUnauthorized()
|
raise web.HTTPUnauthorized()
|
||||||
|
|
||||||
|
if not camera.is_on:
|
||||||
|
_LOGGER.debug('Camera is off.')
|
||||||
|
raise web.HTTPServiceUnavailable()
|
||||||
|
|
||||||
return await self.handle(request, camera)
|
return await self.handle(request, camera)
|
||||||
|
|
||||||
async def handle(self, 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
|
For more details about this platform, please refer to the documentation
|
||||||
https://home-assistant.io/components/demo/
|
https://home-assistant.io/components/demo/
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
import homeassistant.util.dt as dt_util
|
import os
|
||||||
from homeassistant.components.camera import Camera
|
|
||||||
|
from homeassistant.components.camera import Camera, SUPPORT_ON_OFF
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -16,26 +16,29 @@ async def async_setup_platform(hass, config, async_add_devices,
|
|||||||
discovery_info=None):
|
discovery_info=None):
|
||||||
"""Set up the Demo camera platform."""
|
"""Set up the Demo camera platform."""
|
||||||
async_add_devices([
|
async_add_devices([
|
||||||
DemoCamera(hass, config, 'Demo camera')
|
DemoCamera('Demo camera')
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
class DemoCamera(Camera):
|
class DemoCamera(Camera):
|
||||||
"""The representation of a Demo camera."""
|
"""The representation of a Demo camera."""
|
||||||
|
|
||||||
def __init__(self, hass, config, name):
|
def __init__(self, name):
|
||||||
"""Initialize demo camera component."""
|
"""Initialize demo camera component."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._parent = hass
|
|
||||||
self._name = name
|
self._name = name
|
||||||
self._motion_status = False
|
self._motion_status = False
|
||||||
|
self.is_streaming = True
|
||||||
|
self._images_index = 0
|
||||||
|
|
||||||
def camera_image(self):
|
def camera_image(self):
|
||||||
"""Return a faked still image response."""
|
"""Return a faked still image response."""
|
||||||
now = dt_util.utcnow()
|
self._images_index = (self._images_index + 1) % 4
|
||||||
|
|
||||||
image_path = os.path.join(
|
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:
|
with open(image_path, 'rb') as file:
|
||||||
return file.read()
|
return file.read()
|
||||||
|
|
||||||
@ -46,8 +49,21 @@ class DemoCamera(Camera):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def should_poll(self):
|
def should_poll(self):
|
||||||
"""Camera should poll periodically."""
|
"""Demo camera doesn't need poll.
|
||||||
return True
|
|
||||||
|
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
|
@property
|
||||||
def motion_detection_enabled(self):
|
def motion_detection_enabled(self):
|
||||||
@ -57,7 +73,19 @@ class DemoCamera(Camera):
|
|||||||
def enable_motion_detection(self):
|
def enable_motion_detection(self):
|
||||||
"""Enable the Motion detection in base station (Arm)."""
|
"""Enable the Motion detection in base station (Arm)."""
|
||||||
self._motion_status = True
|
self._motion_status = True
|
||||||
|
self.schedule_update_ha_state()
|
||||||
|
|
||||||
def disable_motion_detection(self):
|
def disable_motion_detection(self):
|
||||||
"""Disable the motion detection in base station (Disarm)."""
|
"""Disable the motion detection in base station (Disarm)."""
|
||||||
self._motion_status = False
|
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
|
# 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:
|
enable_motion_detection:
|
||||||
description: Enable the motion detection in a camera.
|
description: Enable the motion detection in a camera.
|
||||||
fields:
|
fields:
|
||||||
|
@ -1,14 +1,89 @@
|
|||||||
"""The tests for local file camera component."""
|
"""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 import camera
|
||||||
|
from homeassistant.components.camera import STATE_STREAMING, STATE_IDLE
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
|
|
||||||
@asyncio.coroutine
|
@pytest.fixture
|
||||||
def test_motion_detection(hass):
|
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."""
|
"""Test motion detection services."""
|
||||||
# Setup platform
|
# Setup platform
|
||||||
yield from async_setup_component(hass, 'camera', {
|
await async_setup_component(hass, 'camera', {
|
||||||
'camera': {
|
'camera': {
|
||||||
'platform': 'demo'
|
'platform': 'demo'
|
||||||
}
|
}
|
||||||
@ -20,7 +95,7 @@ def test_motion_detection(hass):
|
|||||||
|
|
||||||
# Call service to turn on motion detection
|
# Call service to turn on motion detection
|
||||||
camera.enable_motion_detection(hass, 'camera.demo_camera')
|
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.
|
# Check if state has been updated.
|
||||||
state = hass.states.get('camera.demo_camera')
|
state = hass.states.get('camera.demo_camera')
|
||||||
|
Loading…
x
Reference in New Issue
Block a user