Camera Preferences + Preload Stream (#22339)

* initial commit for camera preferences and preload stream

* cleanup and add tests

* respect camera preferences on each request stream call

* return the new prefs after update
This commit is contained in:
Jason Hunter 2019-03-26 08:31:29 -04:00 committed by GitHub
parent baa4945944
commit bad0a8b342
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 252 additions and 22 deletions

View File

@ -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),

View File

@ -0,0 +1,6 @@
"""Constants for Camera component."""
DOMAIN = 'camera'
DATA_CAMERA_PREFS = 'camera_prefs'
PREF_PRELOAD_STREAM = 'preload_stream'

View File

@ -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, {}))

View File

@ -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__)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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