mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 16:57:53 +00:00
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:
parent
baa4945944
commit
bad0a8b342
@ -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),
|
||||
|
6
homeassistant/components/camera/const.py
Normal file
6
homeassistant/components/camera/const.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""Constants for Camera component."""
|
||||
DOMAIN = 'camera'
|
||||
|
||||
DATA_CAMERA_PREFS = 'camera_prefs'
|
||||
|
||||
PREF_PRELOAD_STREAM = 'preload_stream'
|
60
homeassistant/components/camera/prefs.py
Normal file
60
homeassistant/components/camera/prefs.py
Normal 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, {}))
|
@ -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__)
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user