From 58257af28953eff376467f02789ef76884f88d0c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 3 May 2018 16:02:59 -0400 Subject: [PATCH] Add fetching camera thumbnails over websocket (#14231) * Add fetching camera thumbnails over websocket * Lint --- homeassistant/components/camera/__init__.py | 95 +++++++++++++------ homeassistant/components/frontend/__init__.py | 1 + .../components/image_processing/__init__.py | 2 +- homeassistant/components/microsoft_face.py | 2 +- homeassistant/components/websocket_api.py | 7 ++ tests/components/camera/test_init.py | 58 +++++++---- .../image_processing/test_openalpr_cloud.py | 40 ++++---- tests/components/test_microsoft_face.py | 4 +- 8 files changed, 135 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 1fa89bc2241..c1f92965198 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -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()) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 564ba286b96..58cea0e0c66 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -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. diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index de195ce0165..f0cb3a66d52 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -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) diff --git a/homeassistant/components/microsoft_face.py b/homeassistant/components/microsoft_face.py index e99d8d4a5f6..7c167f93142 100644 --- a/homeassistant/components/microsoft_face.py +++ b/homeassistant/components/microsoft_face.py @@ -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: diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index 84c92631572..4989f4f0db2 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -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. diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 465d6276ad5..d0f1425a595 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -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') diff --git a/tests/components/image_processing/test_openalpr_cloud.py b/tests/components/image_processing/test_openalpr_cloud.py index e840bce54f7..50060e08a4b 100644 --- a/tests/components/image_processing/test_openalpr_cloud.py +++ b/tests/components/image_processing/test_openalpr_cloud.py @@ -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 diff --git a/tests/components/test_microsoft_face.py b/tests/components/test_microsoft_face.py index 7a047a73f47..370059a0a09 100644 --- a/tests/components/test_microsoft_face.py +++ b/tests/components/test_microsoft_face.py @@ -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(