Add fetching camera thumbnails over websocket (#14231)

* Add fetching camera thumbnails over websocket

* Lint
pull/14232/merge
Paulus Schoutsen 2018-05-03 16:02:59 -04:00 committed by Pascal Vizeli
parent 4ecce2598a
commit 58257af289
8 changed files with 135 additions and 74 deletions

View File

@ -6,6 +6,7 @@ For more details about this component, please refer to the documentation at
https://home-assistant.io/components/camera/
"""
import asyncio
import base64
import collections
from contextlib import suppress
from datetime import timedelta
@ -13,20 +14,20 @@ import logging
import hashlib
from random import SystemRandom
import aiohttp
import attr
from aiohttp import web
import async_timeout
import voluptuous as vol
from homeassistant.core import callback
from homeassistant.const import (ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE)
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.exceptions import HomeAssistantError
from homeassistant.loader import bind_hass
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa
from homeassistant.components.http import HomeAssistantView, KEY_AUTHENTICATED
from homeassistant.components import websocket_api
import homeassistant.helpers.config_validation as cv
DOMAIN = 'camera'
@ -64,6 +65,20 @@ CAMERA_SERVICE_SNAPSHOT = CAMERA_SERVICE_SCHEMA.extend({
vol.Required(ATTR_FILENAME): cv.template
})
WS_TYPE_CAMERA_THUMBNAIL = 'camera_thumbnail'
SCHEMA_WS_CAMERA_THUMBNAIL = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
'type': WS_TYPE_CAMERA_THUMBNAIL,
'entity_id': cv.entity_id
})
@attr.s
class Image:
"""Represent an image."""
content_type = attr.ib(type=str)
content = attr.ib(type=bytes)
@bind_hass
def enable_motion_detection(hass, entity_id=None):
@ -92,43 +107,40 @@ def async_snapshot(hass, filename, entity_id=None):
@bind_hass
@asyncio.coroutine
def async_get_image(hass, entity_id, timeout=10):
async def async_get_image(hass, entity_id, timeout=10):
"""Fetch an image from a camera entity."""
websession = async_get_clientsession(hass)
state = hass.states.get(entity_id)
component = hass.data.get(DOMAIN)
if state is None:
raise HomeAssistantError(
"No entity '{0}' for grab an image".format(entity_id))
if component is None:
raise HomeAssistantError('Camera component not setup')
url = "{0}{1}".format(
hass.config.api.base_url,
state.attributes.get(ATTR_ENTITY_PICTURE)
)
camera = component.get_entity(entity_id)
try:
if camera is None:
raise HomeAssistantError('Camera not found')
with suppress(asyncio.CancelledError, asyncio.TimeoutError):
with async_timeout.timeout(timeout, loop=hass.loop):
response = yield from websession.get(url)
image = await camera.async_camera_image()
if response.status != 200:
raise HomeAssistantError("Error {0} on {1}".format(
response.status, url))
if image:
return Image(camera.content_type, image)
image = yield from response.read()
return image
except (asyncio.TimeoutError, aiohttp.ClientError):
raise HomeAssistantError("Can't connect to {0}".format(url))
raise HomeAssistantError('Unable to get image')
@asyncio.coroutine
def async_setup(hass, config):
"""Set up the camera component."""
component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL)
component = hass.data[DOMAIN] = \
EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL)
hass.http.register_view(CameraImageView(component))
hass.http.register_view(CameraMjpegStream(component))
hass.components.websocket_api.async_register_command(
WS_TYPE_CAMERA_THUMBNAIL, websocket_camera_thumbnail,
SCHEMA_WS_CAMERA_THUMBNAIL
)
yield from component.async_setup(config)
@ -344,20 +356,20 @@ class Camera(Entity):
@property
def state_attributes(self):
"""Return the camera state attributes."""
attr = {
attrs = {
'access_token': self.access_tokens[-1],
}
if self.model:
attr['model_name'] = self.model
attrs['model_name'] = self.model
if self.brand:
attr['brand'] = self.brand
attrs['brand'] = self.brand
if self.motion_detection_enabled:
attr['motion_detection'] = self.motion_detection_enabled
attrs['motion_detection'] = self.motion_detection_enabled
return attr
return attrs
@callback
def async_update_token(self):
@ -440,3 +452,26 @@ class CameraMjpegStream(CameraView):
return
except ValueError:
return web.Response(status=400)
@callback
def websocket_camera_thumbnail(hass, connection, msg):
"""Handle get camera thumbnail websocket command.
Async friendly.
"""
async def send_camera_still():
"""Send a camera still."""
try:
image = await async_get_image(hass, msg['entity_id'])
connection.send_message_outside(websocket_api.result_message(
msg['id'], {
'content_type': image.content_type,
'content': base64.b64encode(image.content).decode('utf-8')
}
))
except HomeAssistantError:
connection.send_message_outside(websocket_api.error_message(
msg['id'], 'image_fetch_failed', 'Unable to fetch image'))
hass.async_add_job(send_camera_still())

View File

@ -606,6 +606,7 @@ def _is_latest(js_option, request):
return useragent and hass_frontend.version(useragent)
@callback
def websocket_handle_get_panels(hass, connection, msg):
"""Handle get panels command.

View File

@ -132,4 +132,4 @@ class ImageProcessingEntity(Entity):
return
# process image data
yield from self.async_process_image(image)
yield from self.async_process_image(image.content)

View File

@ -239,7 +239,7 @@ def async_setup(hass, config):
'post',
"persongroups/{0}/persons/{1}/persistedFaces".format(
g_id, p_id),
image,
image.content,
binary=True
)
except HomeAssistantError as err:

View File

@ -429,6 +429,7 @@ class ActiveConnection:
return wsock
@callback
def handle_subscribe_events(hass, connection, msg):
"""Handle subscribe events command.
@ -447,6 +448,7 @@ def handle_subscribe_events(hass, connection, msg):
connection.to_write.put_nowait(result_message(msg['id']))
@callback
def handle_unsubscribe_events(hass, connection, msg):
"""Handle unsubscribe events command.
@ -462,6 +464,7 @@ def handle_unsubscribe_events(hass, connection, msg):
msg['id'], ERR_NOT_FOUND, 'Subscription not found.'))
@callback
def handle_call_service(hass, connection, msg):
"""Handle call service command.
@ -476,6 +479,7 @@ def handle_call_service(hass, connection, msg):
hass.async_add_job(call_service_helper(msg))
@callback
def handle_get_states(hass, connection, msg):
"""Handle get states command.
@ -485,6 +489,7 @@ def handle_get_states(hass, connection, msg):
msg['id'], hass.states.async_all()))
@callback
def handle_get_services(hass, connection, msg):
"""Handle get services command.
@ -499,6 +504,7 @@ def handle_get_services(hass, connection, msg):
hass.async_add_job(get_services_helper(msg))
@callback
def handle_get_config(hass, connection, msg):
"""Handle get config command.
@ -508,6 +514,7 @@ def handle_get_config(hass, connection, msg):
msg['id'], hass.config.as_dict()))
@callback
def handle_ping(hass, connection, msg):
"""Handle ping command.

View File

@ -1,18 +1,19 @@
"""The tests for the camera component."""
import asyncio
import base64
from unittest.mock import patch, mock_open
import pytest
from homeassistant.setup import setup_component, async_setup_component
from homeassistant.const import ATTR_ENTITY_PICTURE
import homeassistant.components.camera as camera
import homeassistant.components.http as http
from homeassistant.components import camera, http, websocket_api
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util.async_ import run_coroutine_threadsafe
from tests.common import (
get_test_home_assistant, get_test_instance_port, assert_setup_component)
get_test_home_assistant, get_test_instance_port, assert_setup_component,
mock_coro)
@pytest.fixture
@ -90,36 +91,32 @@ class TestGetImage(object):
self.hass, 'camera.demo_camera'), self.hass.loop).result()
assert mock_camera.called
assert image == b'Test'
assert image.content == b'Test'
def test_get_image_without_exists_camera(self):
"""Try to get image without exists camera."""
self.hass.states.remove('camera.demo_camera')
with pytest.raises(HomeAssistantError):
with patch('homeassistant.helpers.entity_component.EntityComponent.'
'get_entity', return_value=None), \
pytest.raises(HomeAssistantError):
run_coroutine_threadsafe(camera.async_get_image(
self.hass, 'camera.demo_camera'), self.hass.loop).result()
def test_get_image_with_timeout(self, aioclient_mock):
def test_get_image_with_timeout(self):
"""Try to get image with timeout."""
aioclient_mock.get(self.url, exc=asyncio.TimeoutError())
with pytest.raises(HomeAssistantError):
with patch('homeassistant.components.camera.Camera.async_camera_image',
side_effect=asyncio.TimeoutError), \
pytest.raises(HomeAssistantError):
run_coroutine_threadsafe(camera.async_get_image(
self.hass, 'camera.demo_camera'), self.hass.loop).result()
assert len(aioclient_mock.mock_calls) == 1
def test_get_image_with_bad_http_state(self, aioclient_mock):
"""Try to get image with bad http status."""
aioclient_mock.get(self.url, status=400)
with pytest.raises(HomeAssistantError):
def test_get_image_fails(self):
"""Try to get image with timeout."""
with patch('homeassistant.components.camera.Camera.async_camera_image',
return_value=mock_coro(None)), \
pytest.raises(HomeAssistantError):
run_coroutine_threadsafe(camera.async_get_image(
self.hass, 'camera.demo_camera'), self.hass.loop).result()
assert len(aioclient_mock.mock_calls) == 1
@asyncio.coroutine
def test_snapshot_service(hass, mock_camera):
@ -136,3 +133,24 @@ def test_snapshot_service(hass, mock_camera):
assert len(mock_write.mock_calls) == 1
assert mock_write.mock_calls[0][1][0] == b'Test'
async def test_webocket_camera_thumbnail(hass, hass_ws_client, mock_camera):
"""Test camera_thumbnail websocket command."""
await async_setup_component(hass, 'camera')
client = await hass_ws_client(hass)
await client.send_json({
'id': 5,
'type': 'camera_thumbnail',
'entity_id': 'camera.demo_camera',
})
msg = await client.receive_json()
assert msg['id'] == 5
assert msg['type'] == websocket_api.TYPE_RESULT
assert msg['success']
assert msg['result']['content_type'] == 'image/jpeg'
assert msg['result']['content'] == \
base64.b64encode(b'Test').decode('utf-8')

View File

@ -3,14 +3,13 @@ import asyncio
from unittest.mock import patch, PropertyMock
from homeassistant.core import callback
from homeassistant.const import ATTR_ENTITY_PICTURE
from homeassistant.setup import setup_component
import homeassistant.components.image_processing as ip
from homeassistant.components import camera, image_processing as ip
from homeassistant.components.image_processing.openalpr_cloud import (
OPENALPR_API_URL)
from tests.common import (
get_test_home_assistant, assert_setup_component, load_fixture)
get_test_home_assistant, assert_setup_component, load_fixture, mock_coro)
class TestOpenAlprCloudSetup(object):
@ -131,11 +130,6 @@ class TestOpenAlprCloud(object):
new_callable=PropertyMock(return_value=False)):
setup_component(self.hass, ip.DOMAIN, config)
state = self.hass.states.get('camera.demo_camera')
self.url = "{0}{1}".format(
self.hass.config.api.base_url,
state.attributes.get(ATTR_ENTITY_PICTURE))
self.alpr_events = []
@callback
@ -158,18 +152,20 @@ class TestOpenAlprCloud(object):
def test_openalpr_process_image(self, aioclient_mock):
"""Setup and scan a picture and test plates from event."""
aioclient_mock.get(self.url, content=b'image')
aioclient_mock.post(
OPENALPR_API_URL, params=self.params,
text=load_fixture('alpr_cloud.json'), status=200
)
ip.scan(self.hass, entity_id='image_processing.test_local')
self.hass.block_till_done()
with patch('homeassistant.components.camera.async_get_image',
return_value=mock_coro(
camera.Image('image/jpeg', b'image'))):
ip.scan(self.hass, entity_id='image_processing.test_local')
self.hass.block_till_done()
state = self.hass.states.get('image_processing.test_local')
assert len(aioclient_mock.mock_calls) == 2
assert len(aioclient_mock.mock_calls) == 1
assert len(self.alpr_events) == 5
assert state.attributes.get('vehicles') == 1
assert state.state == 'H786P0J'
@ -184,28 +180,32 @@ class TestOpenAlprCloud(object):
def test_openalpr_process_image_api_error(self, aioclient_mock):
"""Setup and scan a picture and test api error."""
aioclient_mock.get(self.url, content=b'image')
aioclient_mock.post(
OPENALPR_API_URL, params=self.params,
text="{'error': 'error message'}", status=400
)
ip.scan(self.hass, entity_id='image_processing.test_local')
self.hass.block_till_done()
with patch('homeassistant.components.camera.async_get_image',
return_value=mock_coro(
camera.Image('image/jpeg', b'image'))):
ip.scan(self.hass, entity_id='image_processing.test_local')
self.hass.block_till_done()
assert len(aioclient_mock.mock_calls) == 2
assert len(aioclient_mock.mock_calls) == 1
assert len(self.alpr_events) == 0
def test_openalpr_process_image_api_timeout(self, aioclient_mock):
"""Setup and scan a picture and test api error."""
aioclient_mock.get(self.url, content=b'image')
aioclient_mock.post(
OPENALPR_API_URL, params=self.params,
exc=asyncio.TimeoutError()
)
ip.scan(self.hass, entity_id='image_processing.test_local')
self.hass.block_till_done()
with patch('homeassistant.components.camera.async_get_image',
return_value=mock_coro(
camera.Image('image/jpeg', b'image'))):
ip.scan(self.hass, entity_id='image_processing.test_local')
self.hass.block_till_done()
assert len(aioclient_mock.mock_calls) == 2
assert len(aioclient_mock.mock_calls) == 1
assert len(self.alpr_events) == 0

View File

@ -2,7 +2,7 @@
import asyncio
from unittest.mock import patch
import homeassistant.components.microsoft_face as mf
from homeassistant.components import camera, microsoft_face as mf
from homeassistant.setup import setup_component
from tests.common import (
@ -190,7 +190,7 @@ class TestMicrosoftFaceSetup(object):
assert len(aioclient_mock.mock_calls) == 1
@patch('homeassistant.components.camera.async_get_image',
return_value=mock_coro(b'Test'))
return_value=mock_coro(camera.Image('image/jpeg', b'Test')))
def test_service_face(self, camera_mock, aioclient_mock):
"""Setup component, test person face services."""
aioclient_mock.get(