Change the API boundary between camera and stream with initial improvement for nest expiring stream urls (#45431)

* Change the API boundary between stream and camera

Shift more of the stream lifecycle management to the camera.  The motivation is to support stream urls that expire
giving the camera the ability to change the stream once it is created.

* Document stream lifecycle and simplify stream/camera interaction

* Reorder create_stream function to reduce diffs

* Increase test coverage for camera_sdm.py

* Fix ffmpeg typo.

* Add a stream identifier for each stream, managed by camera

* Remove stream record service

* Update homeassistant/components/stream/__init__.py

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>

* Unroll changes to Stream interface back into camera component

* Fix preload stream to actually start the background worker

* Reduce unncessary diffs for readability

* Remove redundant camera stream start code

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
Allen Porter 2021-02-08 19:53:28 -08:00 committed by GitHub
parent 889baef456
commit 2bcf87b980
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 254 additions and 356 deletions

View file

@ -23,16 +23,8 @@ from homeassistant.components.media_player.const import (
DOMAIN as DOMAIN_MP, DOMAIN as DOMAIN_MP,
SERVICE_PLAY_MEDIA, SERVICE_PLAY_MEDIA,
) )
from homeassistant.components.stream import request_stream from homeassistant.components.stream import Stream, create_stream
from homeassistant.components.stream.const import ( from homeassistant.components.stream.const import FORMAT_CONTENT_TYPE, OUTPUT_FORMATS
CONF_DURATION,
CONF_LOOKBACK,
CONF_STREAM_SOURCE,
DOMAIN as DOMAIN_STREAM,
FORMAT_CONTENT_TYPE,
OUTPUT_FORMATS,
SERVICE_RECORD,
)
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
CONF_FILENAME, CONF_FILENAME,
@ -53,7 +45,13 @@ from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.network import get_url from homeassistant.helpers.network import get_url
from homeassistant.loader import bind_hass from homeassistant.loader import bind_hass
from .const import DATA_CAMERA_PREFS, DOMAIN from .const import (
CONF_DURATION,
CONF_LOOKBACK,
DATA_CAMERA_PREFS,
DOMAIN,
SERVICE_RECORD,
)
from .prefs import CameraPreferences from .prefs import CameraPreferences
# mypy: allow-untyped-calls, allow-untyped-defs # mypy: allow-untyped-calls, allow-untyped-defs
@ -130,23 +128,7 @@ class Image:
async def async_request_stream(hass, entity_id, fmt): async def async_request_stream(hass, entity_id, fmt):
"""Request a stream for a camera entity.""" """Request a stream for a camera entity."""
camera = _get_camera_from_entity_id(hass, entity_id) camera = _get_camera_from_entity_id(hass, entity_id)
camera_prefs = hass.data[DATA_CAMERA_PREFS].get(entity_id) return await _async_stream_endpoint_url(hass, camera, fmt)
async with async_timeout.timeout(10):
source = await camera.stream_source()
if not source:
raise HomeAssistantError(
f"{camera.entity_id} does not support play stream service"
)
return request_stream(
hass,
source,
fmt=fmt,
keepalive=camera_prefs.preload_stream,
options=camera.stream_options,
)
@bind_hass @bind_hass
@ -267,14 +249,11 @@ async def async_setup(hass, config):
camera_prefs = prefs.get(camera.entity_id) camera_prefs = prefs.get(camera.entity_id)
if not camera_prefs.preload_stream: if not camera_prefs.preload_stream:
continue continue
stream = await camera.create_stream()
async with async_timeout.timeout(10): if not stream:
source = await camera.stream_source()
if not source:
continue continue
stream.add_provider("hls")
request_stream(hass, source, keepalive=True, options=camera.stream_options) stream.start()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, preload_stream) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, preload_stream)
@ -330,6 +309,7 @@ class Camera(Entity):
def __init__(self): def __init__(self):
"""Initialize a camera.""" """Initialize a camera."""
self.is_streaming = False self.is_streaming = False
self.stream = None
self.stream_options = {} self.stream_options = {}
self.content_type = DEFAULT_CONTENT_TYPE self.content_type = DEFAULT_CONTENT_TYPE
self.access_tokens: collections.deque = collections.deque([], 2) self.access_tokens: collections.deque = collections.deque([], 2)
@ -375,6 +355,17 @@ class Camera(Entity):
"""Return the interval between frames of the mjpeg stream.""" """Return the interval between frames of the mjpeg stream."""
return 0.5 return 0.5
async def create_stream(self) -> Stream:
"""Create a Stream for stream_source."""
# There is at most one stream (a decode worker) per camera
if not self.stream:
async with async_timeout.timeout(10):
source = await self.stream_source()
if not source:
return None
self.stream = create_stream(self.hass, source, options=self.stream_options)
return self.stream
async def stream_source(self): async def stream_source(self):
"""Return the source of the stream.""" """Return the source of the stream."""
return None return None
@ -586,24 +577,7 @@ async def ws_camera_stream(hass, connection, msg):
try: try:
entity_id = msg["entity_id"] entity_id = msg["entity_id"]
camera = _get_camera_from_entity_id(hass, entity_id) camera = _get_camera_from_entity_id(hass, entity_id)
camera_prefs = hass.data[DATA_CAMERA_PREFS].get(entity_id) url = await _async_stream_endpoint_url(hass, camera, fmt=msg["format"])
async with async_timeout.timeout(10):
source = await camera.stream_source()
if not source:
raise HomeAssistantError(
f"{camera.entity_id} does not support play stream service"
)
fmt = msg["format"]
url = request_stream(
hass,
source,
fmt=fmt,
keepalive=camera_prefs.preload_stream,
options=camera.stream_options,
)
connection.send_result(msg["id"], {"url": url}) connection.send_result(msg["id"], {"url": url})
except HomeAssistantError as ex: except HomeAssistantError as ex:
_LOGGER.error("Error requesting stream: %s", ex) _LOGGER.error("Error requesting stream: %s", ex)
@ -676,32 +650,17 @@ async def async_handle_snapshot_service(camera, service):
async def async_handle_play_stream_service(camera, service_call): async def async_handle_play_stream_service(camera, service_call):
"""Handle play stream services calls.""" """Handle play stream services calls."""
async with async_timeout.timeout(10): fmt = service_call.data[ATTR_FORMAT]
source = await camera.stream_source() url = await _async_stream_endpoint_url(camera.hass, camera, fmt)
if not source:
raise HomeAssistantError(
f"{camera.entity_id} does not support play stream service"
)
hass = camera.hass 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,
source,
fmt=fmt,
keepalive=camera_prefs.preload_stream,
options=camera.stream_options,
)
data = { data = {
ATTR_MEDIA_CONTENT_ID: f"{get_url(hass)}{url}", ATTR_MEDIA_CONTENT_ID: f"{get_url(hass)}{url}",
ATTR_MEDIA_CONTENT_TYPE: FORMAT_CONTENT_TYPE[fmt], ATTR_MEDIA_CONTENT_TYPE: FORMAT_CONTENT_TYPE[fmt],
} }
# It is required to send a different payload for cast media players # It is required to send a different payload for cast media players
entity_ids = service_call.data[ATTR_MEDIA_PLAYER]
cast_entity_ids = [ cast_entity_ids = [
entity entity
for entity, source in entity_sources(hass).items() for entity, source in entity_sources(hass).items()
@ -740,12 +699,28 @@ async def async_handle_play_stream_service(camera, service_call):
) )
async def _async_stream_endpoint_url(hass, camera, fmt):
stream = await camera.create_stream()
if not stream:
raise HomeAssistantError(
f"{camera.entity_id} does not support play stream service"
)
# Update keepalive setting which manages idle shutdown
camera_prefs = hass.data[DATA_CAMERA_PREFS].get(camera.entity_id)
stream.keepalive = camera_prefs.preload_stream
stream.add_provider(fmt)
stream.start()
return stream.endpoint_url(fmt)
async def async_handle_record_service(camera, call): async def async_handle_record_service(camera, call):
"""Handle stream recording service calls.""" """Handle stream recording service calls."""
async with async_timeout.timeout(10): async with async_timeout.timeout(10):
source = await camera.stream_source() stream = await camera.create_stream()
if not source: if not stream:
raise HomeAssistantError(f"{camera.entity_id} does not support record service") raise HomeAssistantError(f"{camera.entity_id} does not support record service")
hass = camera.hass hass = camera.hass
@ -753,13 +728,6 @@ async def async_handle_record_service(camera, call):
filename.hass = hass filename.hass = hass
video_path = filename.async_render(variables={ATTR_ENTITY_ID: camera}) video_path = filename.async_render(variables={ATTR_ENTITY_ID: camera})
data = { await stream.async_record(
CONF_STREAM_SOURCE: source, video_path, duration=call.data[CONF_DURATION], lookback=call.data[CONF_LOOKBACK]
CONF_FILENAME: video_path,
CONF_DURATION: call.data[CONF_DURATION],
CONF_LOOKBACK: call.data[CONF_LOOKBACK],
}
await hass.services.async_call(
DOMAIN_STREAM, SERVICE_RECORD, data, blocking=True, context=call.context
) )

View file

@ -4,3 +4,8 @@ DOMAIN = "camera"
DATA_CAMERA_PREFS = "camera_prefs" DATA_CAMERA_PREFS = "camera_prefs"
PREF_PRELOAD_STREAM = "preload_stream" PREF_PRELOAD_STREAM = "preload_stream"
SERVICE_RECORD = "record"
CONF_LOOKBACK = "lookback"
CONF_DURATION = "duration"

View file

@ -146,6 +146,13 @@ class NestCamera(Camera):
# Next attempt to catch a url will get a new one # Next attempt to catch a url will get a new one
self._stream = None self._stream = None
return return
# Stop any existing stream worker since the url is invalid. The next
# request for this stream will restart it with the right url.
# Issue #42793 tracks improvements (e.g. preserve keepalive, smoother
# transitions across streams)
if self.stream:
self.stream.stop()
self.stream = None
self._schedule_stream_refresh() self._schedule_stream_refresh()
async def async_will_remove_from_hass(self): async def async_will_remove_from_hass(self):

View file

@ -1,28 +1,35 @@
"""Provide functionality to stream video source.""" """Provide functionality to stream video source.
Components use create_stream with a stream source (e.g. an rtsp url) to create
a new Stream object. Stream manages:
- Background work to fetch and decode a stream
- Desired output formats
- Home Assistant URLs for viewing a stream
- Access tokens for URLs for viewing a stream
A Stream consists of a background worker, and one or more output formats each
with their own idle timeout managed by the stream component. When an output
format is no longer in use, the stream component will expire it. When there
are no active output formats, the background worker is shut down and access
tokens are expired. Alternatively, a Stream can be configured with keepalive
to always keep workers active.
"""
import logging import logging
import secrets import secrets
import threading import threading
import time import time
from types import MappingProxyType from types import MappingProxyType
import voluptuous as vol from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.const import CONF_FILENAME, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv
from homeassistant.loader import bind_hass
from .const import ( from .const import (
ATTR_ENDPOINTS, ATTR_ENDPOINTS,
ATTR_STREAMS, ATTR_STREAMS,
CONF_DURATION,
CONF_LOOKBACK,
CONF_STREAM_SOURCE,
DOMAIN, DOMAIN,
MAX_SEGMENTS, MAX_SEGMENTS,
OUTPUT_IDLE_TIMEOUT, OUTPUT_IDLE_TIMEOUT,
SERVICE_RECORD,
STREAM_RESTART_INCREMENT, STREAM_RESTART_INCREMENT,
STREAM_RESTART_RESET_TIME, STREAM_RESTART_RESET_TIME,
) )
@ -31,20 +38,13 @@ from .hls import async_setup_hls
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
STREAM_SERVICE_SCHEMA = vol.Schema({vol.Required(CONF_STREAM_SOURCE): cv.string})
SERVICE_RECORD_SCHEMA = STREAM_SERVICE_SCHEMA.extend( def create_stream(hass, stream_source, options=None):
{ """Create a stream with the specified identfier based on the source url.
vol.Required(CONF_FILENAME): cv.string,
vol.Optional(CONF_DURATION, default=30): int,
vol.Optional(CONF_LOOKBACK, default=0): int,
}
)
The stream_source is typically an rtsp url and options are passed into
@bind_hass pyav / ffmpeg as options.
def request_stream(hass, stream_source, *, fmt="hls", keepalive=False, options=None): """
"""Set up stream with token."""
if DOMAIN not in hass.config.components: if DOMAIN not in hass.config.components:
raise HomeAssistantError("Stream integration is not set up.") raise HomeAssistantError("Stream integration is not set up.")
@ -59,25 +59,9 @@ def request_stream(hass, stream_source, *, fmt="hls", keepalive=False, options=N
**options, **options,
} }
try: stream = Stream(hass, stream_source, options=options)
streams = hass.data[DOMAIN][ATTR_STREAMS] hass.data[DOMAIN][ATTR_STREAMS].append(stream)
stream = streams.get(stream_source) return stream
if not stream:
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)
if not stream.access_token:
stream.access_token = secrets.token_hex()
stream.start()
return hass.data[DOMAIN][ATTR_ENDPOINTS][fmt].format(stream.access_token)
except Exception as err:
raise HomeAssistantError("Unable to get stream") from err
async def async_setup(hass, config): async def async_setup(hass, config):
@ -92,7 +76,7 @@ async def async_setup(hass, config):
hass.data[DOMAIN] = {} hass.data[DOMAIN] = {}
hass.data[DOMAIN][ATTR_ENDPOINTS] = {} hass.data[DOMAIN][ATTR_ENDPOINTS] = {}
hass.data[DOMAIN][ATTR_STREAMS] = {} hass.data[DOMAIN][ATTR_STREAMS] = []
# Setup HLS # Setup HLS
hls_endpoint = async_setup_hls(hass) hls_endpoint = async_setup_hls(hass)
@ -104,33 +88,25 @@ async def async_setup(hass, config):
@callback @callback
def shutdown(event): def shutdown(event):
"""Stop all stream workers.""" """Stop all stream workers."""
for stream in hass.data[DOMAIN][ATTR_STREAMS].values(): for stream in hass.data[DOMAIN][ATTR_STREAMS]:
stream.keepalive = False stream.keepalive = False
stream.stop() stream.stop()
_LOGGER.info("Stopped stream workers") _LOGGER.info("Stopped stream workers")
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown)
async def async_record(call):
"""Call record stream service handler."""
await async_handle_record_service(hass, call)
hass.services.async_register(
DOMAIN, SERVICE_RECORD, async_record, schema=SERVICE_RECORD_SCHEMA
)
return True return True
class Stream: class Stream:
"""Represents a single stream.""" """Represents a single stream."""
def __init__(self, hass, source, options=None, keepalive=False): def __init__(self, hass, source, options=None):
"""Initialize a stream.""" """Initialize a stream."""
self.hass = hass self.hass = hass
self.source = source self.source = source
self.options = options self.options = options
self.keepalive = keepalive self.keepalive = False
self.access_token = None self.access_token = None
self._thread = None self._thread = None
self._thread_quit = None self._thread_quit = None
@ -139,6 +115,14 @@ class Stream:
if self.options is None: if self.options is None:
self.options = {} self.options = {}
def endpoint_url(self, fmt):
"""Start the stream and returns a url for the output format."""
if fmt not in self._outputs:
raise ValueError(f"Stream is not configured for format '{fmt}'")
if not self.access_token:
self.access_token = secrets.token_hex()
return self.hass.data[DOMAIN][ATTR_ENDPOINTS][fmt].format(self.access_token)
@property @property
def outputs(self): def outputs(self):
"""Return a copy of the stream outputs.""" """Return a copy of the stream outputs."""
@ -244,37 +228,26 @@ class Stream:
self._thread = None self._thread = None
_LOGGER.info("Stopped stream: %s", self.source) _LOGGER.info("Stopped stream: %s", self.source)
async def async_record(self, video_path, duration=30, lookback=5):
async def async_handle_record_service(hass, call): """Make a .mp4 recording from a provided stream."""
"""Handle save video service calls."""
stream_source = call.data[CONF_STREAM_SOURCE]
video_path = call.data[CONF_FILENAME]
duration = call.data[CONF_DURATION]
lookback = call.data[CONF_LOOKBACK]
# Check for file access # Check for file access
if not hass.config.is_allowed_path(video_path): if not self.hass.config.is_allowed_path(video_path):
raise HomeAssistantError(f"Can't write {video_path}, no access to path!") raise HomeAssistantError(f"Can't write {video_path}, no access to path!")
# Check for active stream
streams = hass.data[DOMAIN][ATTR_STREAMS]
stream = streams.get(stream_source)
if not stream:
stream = Stream(hass, stream_source)
streams[stream_source] = stream
# Add recorder # Add recorder
recorder = stream.outputs.get("recorder") recorder = self.outputs.get("recorder")
if recorder: if recorder:
raise HomeAssistantError(f"Stream already recording to {recorder.video_path}!") raise HomeAssistantError(
f"Stream already recording to {recorder.video_path}!"
recorder = stream.add_provider("recorder", timeout=duration) )
recorder = self.add_provider("recorder", timeout=duration)
recorder.video_path = video_path recorder.video_path = video_path
stream.start() self.start()
# Take advantage of lookback # Take advantage of lookback
hls = stream.outputs.get("hls") hls = self.outputs.get("hls")
if lookback > 0 and hls: if lookback > 0 and hls:
num_segments = min(int(lookback // hls.target_duration), MAX_SEGMENTS) num_segments = min(int(lookback // hls.target_duration), MAX_SEGMENTS)
# Wait for latest segment, then add the lookback # Wait for latest segment, then add the lookback

View file

@ -1,15 +1,8 @@
"""Constants for Stream component.""" """Constants for Stream component."""
DOMAIN = "stream" DOMAIN = "stream"
CONF_STREAM_SOURCE = "stream_source"
CONF_LOOKBACK = "lookback"
CONF_DURATION = "duration"
ATTR_ENDPOINTS = "endpoints" ATTR_ENDPOINTS = "endpoints"
ATTR_STREAMS = "streams" ATTR_STREAMS = "streams"
ATTR_KEEPALIVE = "keepalive"
SERVICE_RECORD = "record"
OUTPUT_FORMATS = ["hls"] OUTPUT_FORMATS = ["hls"]

View file

@ -194,11 +194,7 @@ class StreamView(HomeAssistantView):
hass = request.app["hass"] hass = request.app["hass"]
stream = next( stream = next(
( (s for s in hass.data[DOMAIN][ATTR_STREAMS] if s.access_token == token),
s
for s in hass.data[DOMAIN][ATTR_STREAMS].values()
if s.access_token == token
),
None, None,
) )

View file

@ -1,15 +0,0 @@
record:
description: Make a .mp4 recording from a provided stream.
fields:
stream_source:
description: The input source for the stream.
example: "rtsp://my.stream.feed:554"
filename:
description: The file name string.
example: "/tmp/my_stream.mp4"
duration:
description: "Target recording length (in seconds). Default: 30"
example: 30
lookback:
description: "Target lookback period (in seconds) to include in addition to duration. Only available if there is currently an active HLS stream for stream_source. Default: 0"
example: 5

View file

@ -155,13 +155,9 @@ async def test_websocket_camera_thumbnail(hass, hass_ws_client, mock_camera):
async def test_websocket_stream_no_source( async def test_websocket_stream_no_source(
hass, hass_ws_client, mock_camera, mock_stream hass, hass_ws_client, mock_camera, mock_stream
): ):
"""Test camera/stream websocket command.""" """Test camera/stream websocket command with camera with no source."""
await async_setup_component(hass, "camera", {}) await async_setup_component(hass, "camera", {})
with patch(
"homeassistant.components.camera.request_stream",
return_value="http://home.assistant/playlist.m3u8",
) as mock_request_stream:
# Request playlist through WebSocket # Request playlist through WebSocket
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
await client.send_json( await client.send_json(
@ -170,7 +166,6 @@ async def test_websocket_stream_no_source(
msg = await client.receive_json() msg = await client.receive_json()
# Assert WebSocket response # Assert WebSocket response
assert not mock_request_stream.called
assert msg["id"] == 6 assert msg["id"] == 6
assert msg["type"] == TYPE_RESULT assert msg["type"] == TYPE_RESULT
assert not msg["success"] assert not msg["success"]
@ -181,9 +176,9 @@ async def test_websocket_camera_stream(hass, hass_ws_client, mock_camera, mock_s
await async_setup_component(hass, "camera", {}) await async_setup_component(hass, "camera", {})
with patch( with patch(
"homeassistant.components.camera.request_stream", "homeassistant.components.camera.Stream.endpoint_url",
return_value="http://home.assistant/playlist.m3u8", return_value="http://home.assistant/playlist.m3u8",
) as mock_request_stream, patch( ) as mock_stream_view_url, patch(
"homeassistant.components.demo.camera.DemoCamera.stream_source", "homeassistant.components.demo.camera.DemoCamera.stream_source",
return_value="http://example.com", return_value="http://example.com",
): ):
@ -195,7 +190,7 @@ async def test_websocket_camera_stream(hass, hass_ws_client, mock_camera, mock_s
msg = await client.receive_json() msg = await client.receive_json()
# Assert WebSocket response # Assert WebSocket response
assert mock_request_stream.called assert mock_stream_view_url.called
assert msg["id"] == 6 assert msg["id"] == 6
assert msg["type"] == TYPE_RESULT assert msg["type"] == TYPE_RESULT
assert msg["success"] assert msg["success"]
@ -248,9 +243,7 @@ async def test_play_stream_service_no_source(hass, mock_camera, mock_stream):
ATTR_ENTITY_ID: "camera.demo_camera", ATTR_ENTITY_ID: "camera.demo_camera",
camera.ATTR_MEDIA_PLAYER: "media_player.test", camera.ATTR_MEDIA_PLAYER: "media_player.test",
} }
with patch("homeassistant.components.camera.request_stream"), pytest.raises( with pytest.raises(HomeAssistantError):
HomeAssistantError
):
# Call service # Call service
await hass.services.async_call( await hass.services.async_call(
camera.DOMAIN, camera.SERVICE_PLAY_STREAM, data, blocking=True camera.DOMAIN, camera.SERVICE_PLAY_STREAM, data, blocking=True
@ -265,7 +258,7 @@ async def test_handle_play_stream_service(hass, mock_camera, mock_stream):
) )
await async_setup_component(hass, "media_player", {}) await async_setup_component(hass, "media_player", {})
with patch( with patch(
"homeassistant.components.camera.request_stream" "homeassistant.components.camera.Stream.endpoint_url",
) as mock_request_stream, patch( ) as mock_request_stream, patch(
"homeassistant.components.demo.camera.DemoCamera.stream_source", "homeassistant.components.demo.camera.DemoCamera.stream_source",
return_value="http://example.com", return_value="http://example.com",
@ -289,7 +282,7 @@ async def test_no_preload_stream(hass, mock_stream):
"""Test camera preload preference.""" """Test camera preload preference."""
demo_prefs = CameraEntityPreferences({PREF_PRELOAD_STREAM: False}) demo_prefs = CameraEntityPreferences({PREF_PRELOAD_STREAM: False})
with patch( with patch(
"homeassistant.components.camera.request_stream" "homeassistant.components.camera.Stream.endpoint_url",
) as mock_request_stream, patch( ) as mock_request_stream, patch(
"homeassistant.components.camera.prefs.CameraPreferences.get", "homeassistant.components.camera.prefs.CameraPreferences.get",
return_value=demo_prefs, return_value=demo_prefs,
@ -308,8 +301,8 @@ async def test_preload_stream(hass, mock_stream):
"""Test camera preload preference.""" """Test camera preload preference."""
demo_prefs = CameraEntityPreferences({PREF_PRELOAD_STREAM: True}) demo_prefs = CameraEntityPreferences({PREF_PRELOAD_STREAM: True})
with patch( with patch(
"homeassistant.components.camera.request_stream" "homeassistant.components.camera.create_stream"
) as mock_request_stream, patch( ) as mock_create_stream, patch(
"homeassistant.components.camera.prefs.CameraPreferences.get", "homeassistant.components.camera.prefs.CameraPreferences.get",
return_value=demo_prefs, return_value=demo_prefs,
), patch( ), patch(
@ -322,7 +315,7 @@ async def test_preload_stream(hass, mock_stream):
await hass.async_block_till_done() await hass.async_block_till_done()
hass.bus.async_fire(EVENT_HOMEASSISTANT_START) hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
await hass.async_block_till_done() await hass.async_block_till_done()
assert mock_request_stream.called assert mock_create_stream.called
async def test_record_service_invalid_path(hass, mock_camera): async def test_record_service_invalid_path(hass, mock_camera):
@ -348,10 +341,9 @@ async def test_record_service(hass, mock_camera, mock_stream):
"homeassistant.components.demo.camera.DemoCamera.stream_source", "homeassistant.components.demo.camera.DemoCamera.stream_source",
return_value="http://example.com", return_value="http://example.com",
), patch( ), patch(
"homeassistant.components.stream.async_handle_record_service", "homeassistant.components.stream.Stream.async_record",
) as mock_record_service, patch.object( autospec=True,
hass.config, "is_allowed_path", return_value=True ) as mock_record:
):
# Call service # Call service
await hass.services.async_call( await hass.services.async_call(
camera.DOMAIN, camera.DOMAIN,
@ -361,4 +353,4 @@ async def test_record_service(hass, mock_camera, mock_stream):
) )
# So long as we call stream.record, the rest should be covered # So long as we call stream.record, the rest should be covered
# by those tests. # by those tests.
assert mock_record_service.called assert mock_record.called

View file

@ -176,17 +176,18 @@ async def test_stream_source(aioclient_mock, hass, hass_client, hass_ws_client):
"still_image_url": "https://example.com", "still_image_url": "https://example.com",
"stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}', "stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}',
"limit_refetch_to_url_change": True, "limit_refetch_to_url_change": True,
} },
}, },
) )
assert await async_setup_component(hass, "stream", {})
await hass.async_block_till_done() await hass.async_block_till_done()
hass.states.async_set("sensor.temp", "5") hass.states.async_set("sensor.temp", "5")
with patch( with patch(
"homeassistant.components.camera.request_stream", "homeassistant.components.camera.Stream.endpoint_url",
return_value="http://home.assistant/playlist.m3u8", return_value="http://home.assistant/playlist.m3u8",
) as mock_request_stream: ) as mock_stream_url:
# Request playlist through WebSocket # Request playlist through WebSocket
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
@ -196,25 +197,47 @@ async def test_stream_source(aioclient_mock, hass, hass_client, hass_ws_client):
msg = await client.receive_json() msg = await client.receive_json()
# Assert WebSocket response # Assert WebSocket response
assert mock_request_stream.call_count == 1 assert mock_stream_url.call_count == 1
assert mock_request_stream.call_args[0][1] == "http://example.com/5a"
assert msg["id"] == 1 assert msg["id"] == 1
assert msg["type"] == TYPE_RESULT assert msg["type"] == TYPE_RESULT
assert msg["success"] assert msg["success"]
assert msg["result"]["url"][-13:] == "playlist.m3u8" assert msg["result"]["url"][-13:] == "playlist.m3u8"
# Cause a template render error
hass.states.async_remove("sensor.temp") async def test_stream_source_error(aioclient_mock, hass, hass_client, hass_ws_client):
"""Test that the stream source has an error."""
assert await async_setup_component(
hass,
"camera",
{
"camera": {
"name": "config_test",
"platform": "generic",
"still_image_url": "https://example.com",
# Does not exist
"stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}',
"limit_refetch_to_url_change": True,
},
},
)
assert await async_setup_component(hass, "stream", {})
await hass.async_block_till_done()
with patch(
"homeassistant.components.camera.Stream.endpoint_url",
return_value="http://home.assistant/playlist.m3u8",
) as mock_stream_url:
# Request playlist through WebSocket
client = await hass_ws_client(hass)
await client.send_json( await client.send_json(
{"id": 2, "type": "camera/stream", "entity_id": "camera.config_test"} {"id": 1, "type": "camera/stream", "entity_id": "camera.config_test"}
) )
msg = await client.receive_json() msg = await client.receive_json()
# Assert that no new call to the stream request should have been made # Assert WebSocket response
assert mock_request_stream.call_count == 1 assert mock_stream_url.call_count == 0
# Assert the websocket error message assert msg["id"] == 1
assert msg["id"] == 2
assert msg["type"] == TYPE_RESULT assert msg["type"] == TYPE_RESULT
assert msg["success"] is False assert msg["success"] is False
assert msg["error"] == { assert msg["error"] == {
@ -240,7 +263,7 @@ async def test_no_stream_source(aioclient_mock, hass, hass_client, hass_ws_clien
await hass.async_block_till_done() await hass.async_block_till_done()
with patch( with patch(
"homeassistant.components.camera.request_stream", "homeassistant.components.camera.Stream.endpoint_url",
return_value="http://home.assistant/playlist.m3u8", return_value="http://home.assistant/playlist.m3u8",
) as mock_request_stream: ) as mock_request_stream:
# Request playlist through WebSocket # Request playlist through WebSocket

View file

@ -16,6 +16,7 @@ import pytest
from homeassistant.components import camera from homeassistant.components import camera
from homeassistant.components.camera import STATE_IDLE from homeassistant.components.camera import STATE_IDLE
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow from homeassistant.util.dt import utcnow
from .common import async_setup_sdm_platform from .common import async_setup_sdm_platform
@ -245,12 +246,17 @@ async def test_refresh_expired_stream_token(hass, auth):
DEVICE_TRAITS, DEVICE_TRAITS,
auth=auth, auth=auth,
) )
assert await async_setup_component(hass, "stream", {})
assert len(hass.states.async_all()) == 1 assert len(hass.states.async_all()) == 1
cam = hass.states.get("camera.my_camera") cam = hass.states.get("camera.my_camera")
assert cam is not None assert cam is not None
assert cam.state == STATE_IDLE assert cam.state == STATE_IDLE
# Request a stream for the camera entity to exercise nest cam + camera interaction
# and shutdown on url expiration
await camera.async_request_stream(hass, cam.entity_id, "hls")
stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") stream_source = await camera.async_get_stream_source(hass, "camera.my_camera")
assert stream_source == "rtsp://some/url?auth=g.1.streamingToken" assert stream_source == "rtsp://some/url?auth=g.1.streamingToken"

View file

@ -5,9 +5,6 @@ import io
import av import av
import numpy as np import numpy as np
from homeassistant.components.stream import Stream
from homeassistant.components.stream.const import ATTR_STREAMS, DOMAIN
AUDIO_SAMPLE_RATE = 8000 AUDIO_SAMPLE_RATE = 8000
@ -93,10 +90,3 @@ def generate_h264_video(container_format="mp4", audio_codec=None):
output.seek(0) output.seek(0)
return output return output
def preload_stream(hass, stream_source):
"""Preload a stream for use in tests."""
stream = Stream(hass, stream_source)
hass.data[DOMAIN][ATTR_STREAMS][stream_source] = stream
return stream

View file

@ -5,13 +5,13 @@ from urllib.parse import urlparse
import av import av
from homeassistant.components.stream import request_stream from homeassistant.components.stream import create_stream
from homeassistant.const import HTTP_NOT_FOUND from homeassistant.const import HTTP_NOT_FOUND
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from tests.common import async_fire_time_changed from tests.common import async_fire_time_changed
from tests.components.stream.common import generate_h264_video, preload_stream from tests.components.stream.common import generate_h264_video
async def test_hls_stream(hass, hass_client, stream_worker_sync): async def test_hls_stream(hass, hass_client, stream_worker_sync):
@ -27,11 +27,12 @@ async def test_hls_stream(hass, hass_client, stream_worker_sync):
# Setup demo HLS track # Setup demo HLS track
source = generate_h264_video() source = generate_h264_video()
stream = preload_stream(hass, source) stream = create_stream(hass, source)
stream.add_provider("hls")
# Request stream # Request stream
url = request_stream(hass, source) stream.add_provider("hls")
stream.start()
url = stream.endpoint_url("hls")
http_client = await hass_client() http_client = await hass_client()
@ -72,11 +73,12 @@ async def test_stream_timeout(hass, hass_client, stream_worker_sync):
# Setup demo HLS track # Setup demo HLS track
source = generate_h264_video() source = generate_h264_video()
stream = preload_stream(hass, source) stream = create_stream(hass, source)
stream.add_provider("hls")
# Request stream # Request stream
url = request_stream(hass, source) stream.add_provider("hls")
stream.start()
url = stream.endpoint_url("hls")
http_client = await hass_client() http_client = await hass_client()
@ -113,11 +115,13 @@ async def test_stream_ended(hass, stream_worker_sync):
# Setup demo HLS track # Setup demo HLS track
source = generate_h264_video() source = generate_h264_video()
stream = preload_stream(hass, source) stream = create_stream(hass, source)
track = stream.add_provider("hls") track = stream.add_provider("hls")
# Request stream # Request stream
request_stream(hass, source) stream.add_provider("hls")
stream.start()
stream.endpoint_url("hls")
# Run it dead # Run it dead
while True: while True:
@ -142,9 +146,10 @@ async def test_stream_keepalive(hass):
# Setup demo HLS track # Setup demo HLS track
source = "test_stream_keepalive_source" source = "test_stream_keepalive_source"
stream = preload_stream(hass, source) stream = create_stream(hass, source)
track = stream.add_provider("hls") track = stream.add_provider("hls")
track.num_segments = 2 track.num_segments = 2
stream.start()
cur_time = 0 cur_time = 0
@ -163,7 +168,8 @@ async def test_stream_keepalive(hass):
av_open.side_effect = av.error.InvalidDataError(-2, "error") av_open.side_effect = av.error.InvalidDataError(-2, "error")
mock_time.time.side_effect = time_side_effect mock_time.time.side_effect = time_side_effect
# Request stream # Request stream
request_stream(hass, source, keepalive=True) stream.keepalive = True
stream.start()
stream._thread.join() stream._thread.join()
stream._thread = None stream._thread = None
assert av_open.call_count == 2 assert av_open.call_count == 2

View file

@ -1,86 +0,0 @@
"""The tests for stream."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.components.stream.const import (
ATTR_STREAMS,
CONF_LOOKBACK,
CONF_STREAM_SOURCE,
DOMAIN,
SERVICE_RECORD,
)
from homeassistant.const import CONF_FILENAME
from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component
async def test_record_service_invalid_file(hass):
"""Test record service call with invalid file."""
await async_setup_component(hass, "stream", {"stream": {}})
data = {CONF_STREAM_SOURCE: "rtsp://my.video", CONF_FILENAME: "/my/invalid/path"}
with pytest.raises(HomeAssistantError):
await hass.services.async_call(DOMAIN, SERVICE_RECORD, data, blocking=True)
async def test_record_service_init_stream(hass):
"""Test record service call with invalid file."""
await async_setup_component(hass, "stream", {"stream": {}})
data = {CONF_STREAM_SOURCE: "rtsp://my.video", CONF_FILENAME: "/my/invalid/path"}
with patch("homeassistant.components.stream.Stream") as stream_mock, patch.object(
hass.config, "is_allowed_path", return_value=True
):
# Setup stubs
stream_mock.return_value.outputs = {}
# Call Service
await hass.services.async_call(DOMAIN, SERVICE_RECORD, data, blocking=True)
# Assert
assert stream_mock.called
async def test_record_service_existing_record_session(hass):
"""Test record service call with invalid file."""
await async_setup_component(hass, "stream", {"stream": {}})
source = "rtsp://my.video"
data = {CONF_STREAM_SOURCE: source, CONF_FILENAME: "/my/invalid/path"}
# Setup stubs
stream_mock = MagicMock()
stream_mock.return_value.outputs = {"recorder": MagicMock()}
hass.data[DOMAIN][ATTR_STREAMS][source] = stream_mock
with patch.object(hass.config, "is_allowed_path", return_value=True), pytest.raises(
HomeAssistantError
):
# Call Service
await hass.services.async_call(DOMAIN, SERVICE_RECORD, data, blocking=True)
async def test_record_service_lookback(hass):
"""Test record service call with invalid file."""
await async_setup_component(hass, "stream", {"stream": {}})
data = {
CONF_STREAM_SOURCE: "rtsp://my.video",
CONF_FILENAME: "/my/invalid/path",
CONF_LOOKBACK: 4,
}
with patch("homeassistant.components.stream.Stream") as stream_mock, patch.object(
hass.config, "is_allowed_path", return_value=True
):
# Setup stubs
hls_mock = MagicMock()
hls_mock.target_duration = 2
hls_mock.recv = AsyncMock(return_value=None)
stream_mock.return_value.outputs = {"hls": hls_mock}
# Call Service
await hass.services.async_call(DOMAIN, SERVICE_RECORD, data, blocking=True)
assert stream_mock.called
stream_mock.return_value.add_provider.assert_called_once_with(
"recorder", timeout=30
)
assert hls_mock.recv.called

View file

@ -8,13 +8,15 @@ from unittest.mock import patch
import av import av
import pytest import pytest
from homeassistant.components.stream import create_stream
from homeassistant.components.stream.core import Segment from homeassistant.components.stream.core import Segment
from homeassistant.components.stream.recorder import recorder_save_worker from homeassistant.components.stream.recorder import recorder_save_worker
from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from tests.common import async_fire_time_changed from tests.common import async_fire_time_changed
from tests.components.stream.common import generate_h264_video, preload_stream from tests.components.stream.common import generate_h264_video
TEST_TIMEOUT = 10 TEST_TIMEOUT = 10
@ -75,10 +77,11 @@ async def test_record_stream(hass, hass_client, stream_worker_sync, record_worke
# Setup demo track # Setup demo track
source = generate_h264_video() source = generate_h264_video()
stream = preload_stream(hass, source) stream = create_stream(hass, source)
recorder = stream.add_provider("recorder") with patch.object(hass.config, "is_allowed_path", return_value=True):
stream.start() await stream.async_record("/example/path")
recorder = stream.add_provider("recorder")
while True: while True:
segment = await recorder.recv() segment = await recorder.recv()
if not segment: if not segment:
@ -95,6 +98,27 @@ async def test_record_stream(hass, hass_client, stream_worker_sync, record_worke
record_worker_sync.join() record_worker_sync.join()
async def test_record_lookback(
hass, hass_client, stream_worker_sync, record_worker_sync
):
"""Exercise record with loopback."""
await async_setup_component(hass, "stream", {"stream": {}})
source = generate_h264_video()
stream = create_stream(hass, source)
# Start an HLS feed to enable lookback
stream.add_provider("hls")
stream.start()
with patch.object(hass.config, "is_allowed_path", return_value=True):
await stream.async_record("/example/path", lookback=4)
# This test does not need recorder cleanup since it is not fully exercised
stream.stop()
async def test_recorder_timeout(hass, hass_client, stream_worker_sync): async def test_recorder_timeout(hass, hass_client, stream_worker_sync):
""" """
Test recorder timeout. Test recorder timeout.
@ -109,9 +133,11 @@ async def test_recorder_timeout(hass, hass_client, stream_worker_sync):
with patch("homeassistant.components.stream.IdleTimer.fire") as mock_timeout: with patch("homeassistant.components.stream.IdleTimer.fire") as mock_timeout:
# Setup demo track # Setup demo track
source = generate_h264_video() source = generate_h264_video()
stream = preload_stream(hass, source)
recorder = stream.add_provider("recorder", timeout=30) stream = create_stream(hass, source)
stream.start() with patch.object(hass.config, "is_allowed_path", return_value=True):
await stream.async_record("/example/path")
recorder = stream.add_provider("recorder")
await recorder.recv() await recorder.recv()
@ -128,6 +154,19 @@ async def test_recorder_timeout(hass, hass_client, stream_worker_sync):
await hass.async_block_till_done() await hass.async_block_till_done()
async def test_record_path_not_allowed(hass, hass_client):
"""Test where the output path is not allowed by home assistant configuration."""
await async_setup_component(hass, "stream", {"stream": {}})
# Setup demo track
source = generate_h264_video()
stream = create_stream(hass, source)
with patch.object(
hass.config, "is_allowed_path", return_value=False
), pytest.raises(HomeAssistantError):
await stream.async_record("/example/path")
async def test_recorder_save(tmpdir): async def test_recorder_save(tmpdir):
"""Test recorder save.""" """Test recorder save."""
# Setup # Setup
@ -165,9 +204,10 @@ async def test_record_stream_audio(
source = generate_h264_video( source = generate_h264_video(
container_format="mov", audio_codec=a_codec container_format="mov", audio_codec=a_codec
) # mov can store PCM ) # mov can store PCM
stream = preload_stream(hass, source) stream = create_stream(hass, source)
with patch.object(hass.config, "is_allowed_path", return_value=True):
await stream.async_record("/example/path")
recorder = stream.add_provider("recorder") recorder = stream.add_provider("recorder")
stream.start()
while True: while True:
segment = await recorder.recv() segment = await recorder.recv()