Add fetching camera thumbnails over websocket (#14231)

* Add fetching camera thumbnails over websocket

* Lint
This commit is contained in:
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/ https://home-assistant.io/components/camera/
""" """
import asyncio import asyncio
import base64
import collections import collections
from contextlib import suppress from contextlib import suppress
from datetime import timedelta from datetime import timedelta
@ -13,20 +14,20 @@ import logging
import hashlib import hashlib
from random import SystemRandom from random import SystemRandom
import aiohttp import attr
from aiohttp import web from aiohttp import web
import async_timeout 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, ATTR_ENTITY_PICTURE) from homeassistant.const import ATTR_ENTITY_ID
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.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa
from homeassistant.components.http import HomeAssistantView, KEY_AUTHENTICATED from homeassistant.components.http import HomeAssistantView, KEY_AUTHENTICATED
from homeassistant.components import websocket_api
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
DOMAIN = 'camera' DOMAIN = 'camera'
@ -64,6 +65,20 @@ CAMERA_SERVICE_SNAPSHOT = CAMERA_SERVICE_SCHEMA.extend({
vol.Required(ATTR_FILENAME): cv.template 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 @bind_hass
def enable_motion_detection(hass, entity_id=None): def enable_motion_detection(hass, entity_id=None):
@ -92,43 +107,40 @@ def async_snapshot(hass, filename, entity_id=None):
@bind_hass @bind_hass
@asyncio.coroutine async def async_get_image(hass, entity_id, timeout=10):
def async_get_image(hass, entity_id, timeout=10):
"""Fetch an image from a camera entity.""" """Fetch an image from a camera entity."""
websession = async_get_clientsession(hass) component = hass.data.get(DOMAIN)
state = hass.states.get(entity_id)
if state is None: if component is None:
raise HomeAssistantError( raise HomeAssistantError('Camera component not setup')
"No entity '{0}' for grab an image".format(entity_id))
url = "{0}{1}".format( camera = component.get_entity(entity_id)
hass.config.api.base_url,
state.attributes.get(ATTR_ENTITY_PICTURE)
)
try: if camera is None:
raise HomeAssistantError('Camera not found')
with suppress(asyncio.CancelledError, asyncio.TimeoutError):
with async_timeout.timeout(timeout, loop=hass.loop): with async_timeout.timeout(timeout, loop=hass.loop):
response = yield from websession.get(url) image = await camera.async_camera_image()
if response.status != 200: if image:
raise HomeAssistantError("Error {0} on {1}".format( return Image(camera.content_type, image)
response.status, url))
image = yield from response.read() raise HomeAssistantError('Unable to get image')
return image
except (asyncio.TimeoutError, aiohttp.ClientError):
raise HomeAssistantError("Can't connect to {0}".format(url))
@asyncio.coroutine @asyncio.coroutine
def async_setup(hass, config): def async_setup(hass, config):
"""Set up the camera component.""" """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(CameraImageView(component))
hass.http.register_view(CameraMjpegStream(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) yield from component.async_setup(config)
@ -344,20 +356,20 @@ class Camera(Entity):
@property @property
def state_attributes(self): def state_attributes(self):
"""Return the camera state attributes.""" """Return the camera state attributes."""
attr = { attrs = {
'access_token': self.access_tokens[-1], 'access_token': self.access_tokens[-1],
} }
if self.model: if self.model:
attr['model_name'] = self.model attrs['model_name'] = self.model
if self.brand: if self.brand:
attr['brand'] = self.brand attrs['brand'] = self.brand
if self.motion_detection_enabled: if self.motion_detection_enabled:
attr['motion_detection'] = self.motion_detection_enabled attrs['motion_detection'] = self.motion_detection_enabled
return attr return attrs
@callback @callback
def async_update_token(self): def async_update_token(self):
@ -440,3 +452,26 @@ class CameraMjpegStream(CameraView):
return return
except ValueError: except ValueError:
return web.Response(status=400) 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) return useragent and hass_frontend.version(useragent)
@callback
def websocket_handle_get_panels(hass, connection, msg): def websocket_handle_get_panels(hass, connection, msg):
"""Handle get panels command. """Handle get panels command.

View file

@ -132,4 +132,4 @@ class ImageProcessingEntity(Entity):
return return
# process image data # 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', 'post',
"persongroups/{0}/persons/{1}/persistedFaces".format( "persongroups/{0}/persons/{1}/persistedFaces".format(
g_id, p_id), g_id, p_id),
image, image.content,
binary=True binary=True
) )
except HomeAssistantError as err: except HomeAssistantError as err:

View file

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

View file

@ -1,18 +1,19 @@
"""The tests for the camera component.""" """The tests for the camera component."""
import asyncio import asyncio
import base64
from unittest.mock import patch, mock_open from unittest.mock import patch, mock_open
import pytest import pytest
from homeassistant.setup import setup_component, async_setup_component from homeassistant.setup import setup_component, async_setup_component
from homeassistant.const import ATTR_ENTITY_PICTURE from homeassistant.const import ATTR_ENTITY_PICTURE
import homeassistant.components.camera as camera from homeassistant.components import camera, http, websocket_api
import homeassistant.components.http as http
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.util.async_ import run_coroutine_threadsafe from homeassistant.util.async_ import run_coroutine_threadsafe
from tests.common import ( 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 @pytest.fixture
@ -90,36 +91,32 @@ class TestGetImage(object):
self.hass, 'camera.demo_camera'), self.hass.loop).result() self.hass, 'camera.demo_camera'), self.hass.loop).result()
assert mock_camera.called assert mock_camera.called
assert image == b'Test' assert image.content == b'Test'
def test_get_image_without_exists_camera(self): def test_get_image_without_exists_camera(self):
"""Try to get image without exists camera.""" """Try to get image without exists camera."""
self.hass.states.remove('camera.demo_camera') with patch('homeassistant.helpers.entity_component.EntityComponent.'
'get_entity', return_value=None), \
with pytest.raises(HomeAssistantError): pytest.raises(HomeAssistantError):
run_coroutine_threadsafe(camera.async_get_image( run_coroutine_threadsafe(camera.async_get_image(
self.hass, 'camera.demo_camera'), self.hass.loop).result() 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.""" """Try to get image with timeout."""
aioclient_mock.get(self.url, exc=asyncio.TimeoutError()) with patch('homeassistant.components.camera.Camera.async_camera_image',
side_effect=asyncio.TimeoutError), \
with pytest.raises(HomeAssistantError): pytest.raises(HomeAssistantError):
run_coroutine_threadsafe(camera.async_get_image( run_coroutine_threadsafe(camera.async_get_image(
self.hass, 'camera.demo_camera'), self.hass.loop).result() self.hass, 'camera.demo_camera'), self.hass.loop).result()
assert len(aioclient_mock.mock_calls) == 1 def test_get_image_fails(self):
"""Try to get image with timeout."""
def test_get_image_with_bad_http_state(self, aioclient_mock): with patch('homeassistant.components.camera.Camera.async_camera_image',
"""Try to get image with bad http status.""" return_value=mock_coro(None)), \
aioclient_mock.get(self.url, status=400) pytest.raises(HomeAssistantError):
with pytest.raises(HomeAssistantError):
run_coroutine_threadsafe(camera.async_get_image( run_coroutine_threadsafe(camera.async_get_image(
self.hass, 'camera.demo_camera'), self.hass.loop).result() self.hass, 'camera.demo_camera'), self.hass.loop).result()
assert len(aioclient_mock.mock_calls) == 1
@asyncio.coroutine @asyncio.coroutine
def test_snapshot_service(hass, mock_camera): 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 len(mock_write.mock_calls) == 1
assert mock_write.mock_calls[0][1][0] == b'Test' 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 unittest.mock import patch, PropertyMock
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.const import ATTR_ENTITY_PICTURE
from homeassistant.setup import setup_component 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 ( from homeassistant.components.image_processing.openalpr_cloud import (
OPENALPR_API_URL) OPENALPR_API_URL)
from tests.common import ( 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): class TestOpenAlprCloudSetup(object):
@ -131,11 +130,6 @@ class TestOpenAlprCloud(object):
new_callable=PropertyMock(return_value=False)): new_callable=PropertyMock(return_value=False)):
setup_component(self.hass, ip.DOMAIN, config) 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 = [] self.alpr_events = []
@callback @callback
@ -158,18 +152,20 @@ class TestOpenAlprCloud(object):
def test_openalpr_process_image(self, aioclient_mock): def test_openalpr_process_image(self, aioclient_mock):
"""Setup and scan a picture and test plates from event.""" """Setup and scan a picture and test plates from event."""
aioclient_mock.get(self.url, content=b'image')
aioclient_mock.post( aioclient_mock.post(
OPENALPR_API_URL, params=self.params, OPENALPR_API_URL, params=self.params,
text=load_fixture('alpr_cloud.json'), status=200 text=load_fixture('alpr_cloud.json'), status=200
) )
ip.scan(self.hass, entity_id='image_processing.test_local') with patch('homeassistant.components.camera.async_get_image',
self.hass.block_till_done() 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') 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 len(self.alpr_events) == 5
assert state.attributes.get('vehicles') == 1 assert state.attributes.get('vehicles') == 1
assert state.state == 'H786P0J' assert state.state == 'H786P0J'
@ -184,28 +180,32 @@ class TestOpenAlprCloud(object):
def test_openalpr_process_image_api_error(self, aioclient_mock): def test_openalpr_process_image_api_error(self, aioclient_mock):
"""Setup and scan a picture and test api error.""" """Setup and scan a picture and test api error."""
aioclient_mock.get(self.url, content=b'image')
aioclient_mock.post( aioclient_mock.post(
OPENALPR_API_URL, params=self.params, OPENALPR_API_URL, params=self.params,
text="{'error': 'error message'}", status=400 text="{'error': 'error message'}", status=400
) )
ip.scan(self.hass, entity_id='image_processing.test_local') with patch('homeassistant.components.camera.async_get_image',
self.hass.block_till_done() 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 assert len(self.alpr_events) == 0
def test_openalpr_process_image_api_timeout(self, aioclient_mock): def test_openalpr_process_image_api_timeout(self, aioclient_mock):
"""Setup and scan a picture and test api error.""" """Setup and scan a picture and test api error."""
aioclient_mock.get(self.url, content=b'image')
aioclient_mock.post( aioclient_mock.post(
OPENALPR_API_URL, params=self.params, OPENALPR_API_URL, params=self.params,
exc=asyncio.TimeoutError() exc=asyncio.TimeoutError()
) )
ip.scan(self.hass, entity_id='image_processing.test_local') with patch('homeassistant.components.camera.async_get_image',
self.hass.block_till_done() 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 assert len(self.alpr_events) == 0

View file

@ -2,7 +2,7 @@
import asyncio import asyncio
from unittest.mock import patch 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 homeassistant.setup import setup_component
from tests.common import ( from tests.common import (
@ -190,7 +190,7 @@ class TestMicrosoftFaceSetup(object):
assert len(aioclient_mock.mock_calls) == 1 assert len(aioclient_mock.mock_calls) == 1
@patch('homeassistant.components.camera.async_get_image', @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): def test_service_face(self, camera_mock, aioclient_mock):
"""Setup component, test person face services.""" """Setup component, test person face services."""
aioclient_mock.get( aioclient_mock.get(