diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 046b6d3947c..cdd8a844389 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -20,7 +20,7 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, \ - SERVICE_TURN_ON + SERVICE_TURN_ON, EVENT_HOMEASSISTANT_START from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass from homeassistant.helpers.entity import Entity @@ -37,7 +37,9 @@ from homeassistant.components.stream.const import ( from homeassistant.components import websocket_api import homeassistant.helpers.config_validation as cv -DOMAIN = 'camera' +from .const import DOMAIN, DATA_CAMERA_PREFS +from .prefs import CameraPreferences + DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) @@ -68,7 +70,6 @@ ENTITY_IMAGE_URL = '/api/camera_proxy/{0}?token={1}' TOKEN_CHANGE_INTERVAL = timedelta(minutes=5) _RND = SystemRandom() -FALLBACK_STREAM_INTERVAL = 1 # seconds MIN_STREAM_INTERVAL = 0.5 # seconds CAMERA_SERVICE_SCHEMA = vol.Schema({ @@ -103,12 +104,14 @@ class Image: async def async_request_stream(hass, entity_id, fmt): """Request a stream for a camera entity.""" camera = _get_camera_from_entity_id(hass, entity_id) + camera_prefs = hass.data[DATA_CAMERA_PREFS].get(entity_id) if not camera.stream_source: raise HomeAssistantError("{} does not support play stream service" .format(camera.entity_id)) - return request_stream(hass, camera.stream_source, fmt=fmt) + return request_stream(hass, camera.stream_source, fmt=fmt, + keepalive=camera_prefs.preload_stream) @bind_hass @@ -197,6 +200,10 @@ async def async_setup(hass, config): component = hass.data[DOMAIN] = \ EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) + prefs = CameraPreferences(hass) + await prefs.async_initialize() + hass.data[DATA_CAMERA_PREFS] = prefs + hass.http.register_view(CameraImageView(component)) hass.http.register_view(CameraMjpegStream(component)) hass.components.websocket_api.async_register_command( @@ -204,9 +211,21 @@ async def async_setup(hass, config): SCHEMA_WS_CAMERA_THUMBNAIL ) hass.components.websocket_api.async_register_command(ws_camera_stream) + hass.components.websocket_api.async_register_command(websocket_get_prefs) + hass.components.websocket_api.async_register_command( + websocket_update_prefs) await component.async_setup(config) + @callback + def preload_stream(event): + for camera in component.entities: + camera_prefs = prefs.get(camera.entity_id) + if camera.stream_source and camera_prefs.preload_stream: + request_stream(hass, camera.stream_source, keepalive=True) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, preload_stream) + @callback def update_tokens(time): """Update tokens of the entities.""" @@ -522,14 +541,17 @@ async def ws_camera_stream(hass, connection, msg): Async friendly. """ try: - camera = _get_camera_from_entity_id(hass, msg['entity_id']) + entity_id = msg['entity_id'] + camera = _get_camera_from_entity_id(hass, entity_id) + camera_prefs = hass.data[DATA_CAMERA_PREFS].get(entity_id) if not camera.stream_source: raise HomeAssistantError("{} does not support play stream service" .format(camera.entity_id)) fmt = msg['format'] - url = request_stream(hass, camera.stream_source, fmt=fmt) + url = request_stream(hass, camera.stream_source, fmt=fmt, + keepalive=camera_prefs.preload_stream) connection.send_result(msg['id'], {'url': url}) except HomeAssistantError as ex: _LOGGER.error(ex) @@ -537,6 +559,36 @@ async def ws_camera_stream(hass, connection, msg): msg['id'], 'start_stream_failed', str(ex)) +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required('type'): 'camera/get_prefs', + vol.Required('entity_id'): cv.entity_id, +}) +async def websocket_get_prefs(hass, connection, msg): + """Handle request for account info.""" + prefs = hass.data[DATA_CAMERA_PREFS].get(msg['entity_id']) + connection.send_result(msg['id'], prefs.as_dict()) + + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required('type'): 'camera/update_prefs', + vol.Required('entity_id'): cv.entity_id, + vol.Optional('preload_stream'): bool, +}) +async def websocket_update_prefs(hass, connection, msg): + """Handle request for account info.""" + prefs = hass.data[DATA_CAMERA_PREFS] + + changes = dict(msg) + changes.pop('id') + changes.pop('type') + entity_id = changes.pop('entity_id') + await prefs.async_update(entity_id, **changes) + + connection.send_result(msg['id'], prefs.get(entity_id).as_dict()) + + async def async_handle_snapshot_service(camera, service): """Handle snapshot services calls.""" hass = camera.hass @@ -573,10 +625,12 @@ async def async_handle_play_stream_service(camera, service_call): .format(camera.entity_id)) hass = camera.hass + camera_prefs = hass.data[DATA_CAMERA_PREFS].get(camera.entity_id) fmt = service_call.data[ATTR_FORMAT] entity_ids = service_call.data[ATTR_MEDIA_PLAYER] - url = request_stream(hass, camera.stream_source, fmt=fmt) + url = request_stream(hass, camera.stream_source, fmt=fmt, + keepalive=camera_prefs.preload_stream) data = { ATTR_ENTITY_ID: entity_ids, ATTR_MEDIA_CONTENT_ID: "{}{}".format(hass.config.api.base_url, url), diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py new file mode 100644 index 00000000000..f87ca47460e --- /dev/null +++ b/homeassistant/components/camera/const.py @@ -0,0 +1,6 @@ +"""Constants for Camera component.""" +DOMAIN = 'camera' + +DATA_CAMERA_PREFS = 'camera_prefs' + +PREF_PRELOAD_STREAM = 'preload_stream' diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py new file mode 100644 index 00000000000..927929bdf6e --- /dev/null +++ b/homeassistant/components/camera/prefs.py @@ -0,0 +1,60 @@ +"""Preference management for camera component.""" +from .const import DOMAIN, PREF_PRELOAD_STREAM + +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 +_UNDEF = object() + + +class CameraEntityPreferences: + """Handle preferences for camera entity.""" + + def __init__(self, prefs): + """Initialize prefs.""" + self._prefs = prefs + + def as_dict(self): + """Return dictionary version.""" + return self._prefs + + @property + def preload_stream(self): + """Return if stream is loaded on hass start.""" + return self._prefs.get(PREF_PRELOAD_STREAM, False) + + +class CameraPreferences: + """Handle camera preferences.""" + + def __init__(self, hass): + """Initialize camera prefs.""" + self._hass = hass + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._prefs = None + + async def async_initialize(self): + """Finish initializing the preferences.""" + prefs = await self._store.async_load() + + if prefs is None: + prefs = {} + + self._prefs = prefs + + async def async_update(self, entity_id, *, preload_stream=_UNDEF, + stream_options=_UNDEF): + """Update camera preferences.""" + if not self._prefs.get(entity_id): + self._prefs[entity_id] = {} + + for key, value in ( + (PREF_PRELOAD_STREAM, preload_stream), + ): + if value is not _UNDEF: + self._prefs[entity_id][key] = value + + await self._store.async_save(self._prefs) + + def get(self, entity_id): + """Get preferences for an entity.""" + return CameraEntityPreferences(self._prefs.get(entity_id, {})) diff --git a/homeassistant/components/local_file/camera.py b/homeassistant/components/local_file/camera.py index d306509b762..56780d16f56 100644 --- a/homeassistant/components/local_file/camera.py +++ b/homeassistant/components/local_file/camera.py @@ -12,7 +12,8 @@ import voluptuous as vol from homeassistant.const import CONF_NAME from homeassistant.components.camera import ( - Camera, CAMERA_SERVICE_SCHEMA, DOMAIN, PLATFORM_SCHEMA) + Camera, CAMERA_SERVICE_SCHEMA, PLATFORM_SCHEMA) +from homeassistant.components.camera.const import DOMAIN from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/logi_circle/camera.py b/homeassistant/components/logi_circle/camera.py index 814475d04de..ff6f14431d5 100644 --- a/homeassistant/components/logi_circle/camera.py +++ b/homeassistant/components/logi_circle/camera.py @@ -6,8 +6,9 @@ import logging import voluptuous as vol from homeassistant.components.camera import ( - ATTR_ENTITY_ID, ATTR_FILENAME, CAMERA_SERVICE_SCHEMA, DOMAIN, + ATTR_ENTITY_ID, ATTR_FILENAME, CAMERA_SERVICE_SCHEMA, PLATFORM_SCHEMA, SUPPORT_ON_OFF, Camera) +from homeassistant.components.camera.const import DOMAIN from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, CONF_SCAN_INTERVAL, STATE_OFF, STATE_ON) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index da0bae7c50b..f3b25e3a128 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -13,7 +13,8 @@ import voluptuous as vol from homeassistant.const import ( CONF_NAME, CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, ATTR_ENTITY_ID) -from homeassistant.components.camera import Camera, PLATFORM_SCHEMA, DOMAIN +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA +from homeassistant.components.camera.const import DOMAIN from homeassistant.components.ffmpeg import ( DATA_FFMPEG, CONF_EXTRA_ARGUMENTS) import homeassistant.helpers.config_validation as cv diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py index 36c4a3109ba..5490cd1508c 100644 --- a/homeassistant/components/push/camera.py +++ b/homeassistant/components/push/camera.py @@ -14,7 +14,8 @@ import aiohttp import async_timeout from homeassistant.components.camera import Camera, PLATFORM_SCHEMA,\ - STATE_IDLE, STATE_RECORDING, DOMAIN + STATE_IDLE, STATE_RECORDING +from homeassistant.components.camera.const import DOMAIN from homeassistant.core import callback from homeassistant.const import CONF_NAME, CONF_TIMEOUT, CONF_WEBHOOK_ID from homeassistant.helpers import config_validation as cv diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index c881ec1276a..a68f1c47dbf 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -56,6 +56,9 @@ def request_stream(hass, stream_source, *, fmt='hls', stream = Stream(hass, stream_source, options=options, keepalive=keepalive) streams[stream_source] = stream + else: + # Update keepalive option on existing stream + stream.keepalive = keepalive # Add provider stream.add_provider(fmt) diff --git a/tests/components/camera/common.py b/tests/components/camera/common.py index 21f7244bd29..bebb991a7af 100644 --- a/tests/components/camera/common.py +++ b/tests/components/camera/common.py @@ -4,7 +4,9 @@ All containing methods are legacy helpers that should not be used by new components. Instead call the service directly. """ from homeassistant.components.camera import ( - ATTR_FILENAME, DOMAIN, SERVICE_ENABLE_MOTION, SERVICE_SNAPSHOT) + ATTR_FILENAME, SERVICE_ENABLE_MOTION, SERVICE_SNAPSHOT) +from homeassistant.components.camera.const import ( + DOMAIN, DATA_CAMERA_PREFS, PREF_PRELOAD_STREAM) from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, \ SERVICE_TURN_ON from homeassistant.core import callback @@ -45,3 +47,13 @@ def async_snapshot(hass, filename, entity_id=None): hass.async_add_job(hass.services.async_call( DOMAIN, SERVICE_SNAPSHOT, data)) + + +def mock_camera_prefs(hass, entity_id, prefs={}): + """Fixture for cloud component.""" + prefs_to_set = { + PREF_PRELOAD_STREAM: True, + } + prefs_to_set.update(prefs) + hass.data[DATA_CAMERA_PREFS]._prefs[entity_id] = prefs_to_set + return prefs_to_set diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 0359d14df63..701a3682830 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -1,13 +1,17 @@ """The tests for the camera component.""" import asyncio import base64 +import io from unittest.mock import patch, mock_open, PropertyMock import pytest from homeassistant.setup import setup_component, async_setup_component -from homeassistant.const import (ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, EVENT_HOMEASSISTANT_START) from homeassistant.components import camera, http +from homeassistant.components.camera.const import DOMAIN, PREF_PRELOAD_STREAM +from homeassistant.components.camera.prefs import CameraEntityPreferences from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.exceptions import HomeAssistantError from homeassistant.util.async_ import run_coroutine_threadsafe @@ -16,7 +20,6 @@ from tests.common import ( get_test_home_assistant, get_test_instance_port, assert_setup_component, mock_coro) from tests.components.camera import common -from tests.components.stream.common import generate_h264_video @pytest.fixture @@ -41,6 +44,12 @@ def mock_stream(hass): })) +@pytest.fixture +def setup_camera_prefs(hass): + """Initialize HTTP API.""" + return common.mock_camera_prefs(hass, 'camera.demo_camera') + + class TestSetupCamera: """Test class for setup camera.""" @@ -146,7 +155,7 @@ def test_snapshot_service(hass, mock_camera): assert mock_write.mock_calls[0][1][0] == b'Test' -async def test_webocket_camera_thumbnail(hass, hass_ws_client, mock_camera): +async def test_websocket_camera_thumbnail(hass, hass_ws_client, mock_camera): """Test camera_thumbnail websocket command.""" await async_setup_component(hass, 'camera') @@ -167,8 +176,8 @@ async def test_webocket_camera_thumbnail(hass, hass_ws_client, mock_camera): base64.b64encode(b'Test').decode('utf-8') -async def test_webocket_stream_no_source(hass, hass_ws_client, - mock_camera, mock_stream): +async def test_websocket_stream_no_source(hass, hass_ws_client, + mock_camera, mock_stream): """Test camera/stream websocket command.""" await async_setup_component(hass, 'camera') @@ -191,8 +200,8 @@ async def test_webocket_stream_no_source(hass, hass_ws_client, assert not msg['success'] -async def test_webocket_camera_stream(hass, hass_ws_client, hass_client, - mock_camera, mock_stream): +async def test_websocket_camera_stream(hass, hass_ws_client, + mock_camera, mock_stream): """Test camera/stream websocket command.""" await async_setup_component(hass, 'camera') @@ -201,7 +210,7 @@ async def test_webocket_camera_stream(hass, hass_ws_client, hass_client, ) as mock_request_stream, \ patch('homeassistant.components.demo.camera.DemoCamera.stream_source', new_callable=PropertyMock) as mock_stream_source: - mock_stream_source.return_value = generate_h264_video() + mock_stream_source.return_value = io.BytesIO() # Request playlist through WebSocket client = await hass_ws_client(hass) await client.send_json({ @@ -219,6 +228,44 @@ async def test_webocket_camera_stream(hass, hass_ws_client, hass_client, assert msg['result']['url'][-13:] == 'playlist.m3u8' +async def test_websocket_get_prefs(hass, hass_ws_client, + mock_camera): + """Test get camera preferences websocket command.""" + await async_setup_component(hass, 'camera') + + # Request preferences through websocket + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 7, + 'type': 'camera/get_prefs', + 'entity_id': 'camera.demo_camera', + }) + msg = await client.receive_json() + + # Assert WebSocket response + assert msg['success'] + + +async def test_websocket_update_prefs(hass, hass_ws_client, + mock_camera, setup_camera_prefs): + """Test updating preference.""" + await async_setup_component(hass, 'camera') + assert setup_camera_prefs[PREF_PRELOAD_STREAM] + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 8, + 'type': 'camera/update_prefs', + 'entity_id': 'camera.demo_camera', + 'preload_stream': False, + }) + response = await client.receive_json() + + assert response['success'] + assert not setup_camera_prefs[PREF_PRELOAD_STREAM] + assert response['result'][PREF_PRELOAD_STREAM] == \ + setup_camera_prefs[PREF_PRELOAD_STREAM] + + async def test_play_stream_service_no_source(hass, mock_camera, mock_stream): """Test camera play_stream service.""" data = { @@ -243,10 +290,54 @@ async def test_handle_play_stream_service(hass, mock_camera, mock_stream): ) as mock_request_stream, \ patch('homeassistant.components.demo.camera.DemoCamera.stream_source', new_callable=PropertyMock) as mock_stream_source: - mock_stream_source.return_value = generate_h264_video() + mock_stream_source.return_value = io.BytesIO() # Call service await hass.services.async_call( camera.DOMAIN, camera.SERVICE_PLAY_STREAM, data, blocking=True) # So long as we request the stream, the rest should be covered # by the play_media service tests. assert mock_request_stream.called + + +async def test_no_preload_stream(hass, mock_stream): + """Test camera preload preference.""" + demo_prefs = CameraEntityPreferences({ + PREF_PRELOAD_STREAM: False, + }) + with patch('homeassistant.components.camera.request_stream' + ) as mock_request_stream, \ + patch('homeassistant.components.camera.prefs.CameraPreferences.get', + return_value=demo_prefs), \ + patch('homeassistant.components.demo.camera.DemoCamera.stream_source', + new_callable=PropertyMock) as mock_stream_source: + mock_stream_source.return_value = io.BytesIO() + await async_setup_component(hass, 'camera', { + DOMAIN: { + 'platform': 'demo' + } + }) + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + assert not mock_request_stream.called + + +async def test_preload_stream(hass, mock_stream): + """Test camera preload preference.""" + demo_prefs = CameraEntityPreferences({ + PREF_PRELOAD_STREAM: True, + }) + with patch('homeassistant.components.camera.request_stream' + ) as mock_request_stream, \ + patch('homeassistant.components.camera.prefs.CameraPreferences.get', + return_value=demo_prefs), \ + patch('homeassistant.components.demo.camera.DemoCamera.stream_source', + new_callable=PropertyMock) as mock_stream_source: + mock_stream_source.return_value = io.BytesIO() + await async_setup_component(hass, 'camera', { + DOMAIN: { + 'platform': 'demo' + } + }) + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + assert mock_request_stream.called diff --git a/tests/components/local_file/test_camera.py b/tests/components/local_file/test_camera.py index 3d70e3f77a7..a96f9768be4 100644 --- a/tests/components/local_file/test_camera.py +++ b/tests/components/local_file/test_camera.py @@ -2,7 +2,7 @@ import asyncio from unittest import mock -from homeassistant.components.camera import DOMAIN +from homeassistant.components.camera.const import DOMAIN from homeassistant.components.local_file.camera import ( SERVICE_UPDATE_FILE_PATH) from homeassistant.setup import async_setup_component