Add fetching camera thumbnails over websocket (#14231)
* Add fetching camera thumbnails over websocket * Lintpull/14232/merge
parent
4ecce2598a
commit
58257af289
|
@ -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())
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in New Issue