2019-03-28 04:47:07 +00:00
|
|
|
"""The tests for hls streams."""
|
2021-02-16 14:59:43 +00:00
|
|
|
import asyncio
|
2019-03-28 04:47:07 +00:00
|
|
|
from datetime import timedelta
|
2021-01-20 13:44:24 +00:00
|
|
|
import logging
|
|
|
|
import os
|
|
|
|
import threading
|
2021-01-01 21:31:56 +00:00
|
|
|
from unittest.mock import patch
|
2019-10-14 21:20:18 +00:00
|
|
|
|
2021-02-16 14:59:43 +00:00
|
|
|
import async_timeout
|
2020-08-20 03:18:54 +00:00
|
|
|
import av
|
2019-04-28 11:58:19 +00:00
|
|
|
import pytest
|
2019-03-28 04:47:07 +00:00
|
|
|
|
2021-02-09 03:53:28 +00:00
|
|
|
from homeassistant.components.stream import create_stream
|
2019-03-28 04:47:07 +00:00
|
|
|
from homeassistant.components.stream.core import Segment
|
|
|
|
from homeassistant.components.stream.recorder import recorder_save_worker
|
2021-02-09 03:53:28 +00:00
|
|
|
from homeassistant.exceptions import HomeAssistantError
|
2019-10-14 21:20:18 +00:00
|
|
|
from homeassistant.setup import async_setup_component
|
2019-03-28 04:47:07 +00:00
|
|
|
import homeassistant.util.dt as dt_util
|
|
|
|
|
|
|
|
from tests.common import async_fire_time_changed
|
2021-02-09 03:53:28 +00:00
|
|
|
from tests.components.stream.common import generate_h264_video
|
2019-03-28 04:47:07 +00:00
|
|
|
|
2021-01-20 13:44:24 +00:00
|
|
|
TEST_TIMEOUT = 10
|
2019-03-28 04:47:07 +00:00
|
|
|
|
2021-01-20 13:44:24 +00:00
|
|
|
|
|
|
|
class SaveRecordWorkerSync:
|
|
|
|
"""
|
|
|
|
Test fixture to manage RecordOutput thread for recorder_save_worker.
|
|
|
|
|
|
|
|
This is used to assert that the worker is started and stopped cleanly
|
|
|
|
to avoid thread leaks in tests.
|
|
|
|
"""
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
"""Initialize SaveRecordWorkerSync."""
|
|
|
|
self.reset()
|
2021-02-16 14:59:43 +00:00
|
|
|
self._segments = None
|
2021-01-20 13:44:24 +00:00
|
|
|
|
2021-02-16 14:59:43 +00:00
|
|
|
def recorder_save_worker(self, file_out, segments, container_format):
|
2021-01-20 13:44:24 +00:00
|
|
|
"""Mock method for patch."""
|
|
|
|
logging.debug("recorder_save_worker thread started")
|
2021-02-16 14:59:43 +00:00
|
|
|
self._segments = segments
|
2021-01-20 13:44:24 +00:00
|
|
|
assert self._save_thread is None
|
|
|
|
self._save_thread = threading.current_thread()
|
|
|
|
self._save_event.set()
|
|
|
|
|
2021-02-16 14:59:43 +00:00
|
|
|
async def get_segments(self):
|
|
|
|
"""Verify save worker thread was invoked and return saved segments."""
|
|
|
|
with async_timeout.timeout(TEST_TIMEOUT):
|
|
|
|
assert await self._save_event.wait()
|
|
|
|
return self._segments
|
|
|
|
|
2021-01-20 13:44:24 +00:00
|
|
|
def join(self):
|
2021-02-16 14:59:43 +00:00
|
|
|
"""Block until the record worker thread exist to ensure cleanup."""
|
2021-01-20 13:44:24 +00:00
|
|
|
self._save_thread.join()
|
|
|
|
|
|
|
|
def reset(self):
|
|
|
|
"""Reset callback state for reuse in tests."""
|
|
|
|
self._save_thread = None
|
2021-02-16 14:59:43 +00:00
|
|
|
self._save_event = asyncio.Event()
|
2021-01-20 13:44:24 +00:00
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture()
|
|
|
|
def record_worker_sync(hass):
|
|
|
|
"""Patch recorder_save_worker for clean thread shutdown for test."""
|
|
|
|
sync = SaveRecordWorkerSync()
|
|
|
|
with patch(
|
|
|
|
"homeassistant.components.stream.recorder.recorder_save_worker",
|
|
|
|
side_effect=sync.recorder_save_worker,
|
|
|
|
autospec=True,
|
|
|
|
):
|
|
|
|
yield sync
|
|
|
|
|
|
|
|
|
2021-02-16 14:59:43 +00:00
|
|
|
async def test_record_stream(hass, hass_client, record_worker_sync):
|
2019-03-28 04:47:07 +00:00
|
|
|
"""
|
|
|
|
Test record stream.
|
|
|
|
|
2021-01-20 13:44:24 +00:00
|
|
|
Tests full integration with the stream component, and captures the
|
|
|
|
stream worker and save worker to allow for clean shutdown of background
|
|
|
|
threads. The actual save logic is tested in test_recorder_save below.
|
2019-03-28 04:47:07 +00:00
|
|
|
"""
|
2019-07-31 19:25:30 +00:00
|
|
|
await async_setup_component(hass, "stream", {"stream": {}})
|
2019-03-28 04:47:07 +00:00
|
|
|
|
2021-01-20 13:44:24 +00:00
|
|
|
# Setup demo track
|
|
|
|
source = generate_h264_video()
|
2021-02-09 03:53:28 +00:00
|
|
|
stream = create_stream(hass, source)
|
|
|
|
with patch.object(hass.config, "is_allowed_path", return_value=True):
|
|
|
|
await stream.async_record("/example/path")
|
2019-03-28 04:47:07 +00:00
|
|
|
|
2021-02-16 14:59:43 +00:00
|
|
|
segments = await record_worker_sync.get_segments()
|
|
|
|
assert len(segments) > 1
|
2021-01-20 13:44:24 +00:00
|
|
|
record_worker_sync.join()
|
2019-03-28 04:47:07 +00:00
|
|
|
|
2021-01-20 13:44:24 +00:00
|
|
|
|
2021-02-09 03:53:28 +00:00
|
|
|
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)
|
|
|
|
|
2021-02-16 14:59:43 +00:00
|
|
|
# Don't let the stream finish (and clean itself up) until the test has had
|
|
|
|
# a chance to perform lookback
|
|
|
|
stream_worker_sync.pause()
|
|
|
|
|
2021-02-09 03:53:28 +00:00
|
|
|
# Start an HLS feed to enable lookback
|
2021-02-16 14:59:43 +00:00
|
|
|
stream.hls_output()
|
2021-02-09 03:53:28 +00:00
|
|
|
|
|
|
|
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
|
2021-02-16 14:59:43 +00:00
|
|
|
stream_worker_sync.resume()
|
2021-02-09 03:53:28 +00:00
|
|
|
stream.stop()
|
|
|
|
|
|
|
|
|
2021-02-16 14:59:43 +00:00
|
|
|
async def test_recorder_timeout(
|
|
|
|
hass, hass_client, stream_worker_sync, record_worker_sync
|
|
|
|
):
|
2021-01-20 13:44:24 +00:00
|
|
|
"""
|
|
|
|
Test recorder timeout.
|
|
|
|
|
|
|
|
Mocks out the cleanup to assert that it is invoked after a timeout.
|
|
|
|
This test does not start the recorder save thread.
|
|
|
|
"""
|
2019-07-31 19:25:30 +00:00
|
|
|
await async_setup_component(hass, "stream", {"stream": {}})
|
2019-03-28 04:47:07 +00:00
|
|
|
|
2021-01-20 13:44:24 +00:00
|
|
|
stream_worker_sync.pause()
|
|
|
|
|
2021-02-08 15:19:41 +00:00
|
|
|
with patch("homeassistant.components.stream.IdleTimer.fire") as mock_timeout:
|
2019-03-28 04:47:07 +00:00
|
|
|
# Setup demo track
|
|
|
|
source = generate_h264_video()
|
2021-02-09 03:53:28 +00:00
|
|
|
|
|
|
|
stream = create_stream(hass, source)
|
|
|
|
with patch.object(hass.config, "is_allowed_path", return_value=True):
|
|
|
|
await stream.async_record("/example/path")
|
2019-03-28 04:47:07 +00:00
|
|
|
|
2021-02-16 14:59:43 +00:00
|
|
|
assert not mock_timeout.called
|
2019-03-28 04:47:07 +00:00
|
|
|
|
|
|
|
# Wait a minute
|
|
|
|
future = dt_util.utcnow() + timedelta(minutes=1)
|
|
|
|
async_fire_time_changed(hass, future)
|
|
|
|
await hass.async_block_till_done()
|
|
|
|
|
2021-02-08 15:19:41 +00:00
|
|
|
assert mock_timeout.called
|
2019-03-28 04:47:07 +00:00
|
|
|
|
2021-01-20 13:44:24 +00:00
|
|
|
stream_worker_sync.resume()
|
2021-02-16 14:59:43 +00:00
|
|
|
# Verify worker is invoked, and do clean shutdown of worker thread
|
|
|
|
await record_worker_sync.get_segments()
|
|
|
|
record_worker_sync.join()
|
|
|
|
|
2021-01-20 13:44:24 +00:00
|
|
|
stream.stop()
|
|
|
|
|
2019-03-28 04:47:07 +00:00
|
|
|
|
2021-02-09 03:53:28 +00:00
|
|
|
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")
|
|
|
|
|
|
|
|
|
2021-01-20 13:44:24 +00:00
|
|
|
async def test_recorder_save(tmpdir):
|
2019-03-28 04:47:07 +00:00
|
|
|
"""Test recorder save."""
|
|
|
|
# Setup
|
|
|
|
source = generate_h264_video()
|
2021-01-20 13:44:24 +00:00
|
|
|
filename = f"{tmpdir}/test.mp4"
|
2019-03-28 04:47:07 +00:00
|
|
|
|
|
|
|
# Run
|
2021-01-20 13:44:24 +00:00
|
|
|
recorder_save_worker(filename, [Segment(1, source, 4)], "mp4")
|
2019-03-28 04:47:07 +00:00
|
|
|
|
|
|
|
# Assert
|
2021-01-20 13:44:24 +00:00
|
|
|
assert os.path.exists(filename)
|
2020-08-20 03:18:54 +00:00
|
|
|
|
|
|
|
|
2021-02-16 14:59:43 +00:00
|
|
|
async def test_record_stream_audio(hass, hass_client, record_worker_sync):
|
2020-08-20 03:18:54 +00:00
|
|
|
"""
|
|
|
|
Test treatment of different audio inputs.
|
|
|
|
|
|
|
|
Record stream output should have an audio channel when input has
|
|
|
|
a valid codec and audio packets and no audio channel otherwise.
|
|
|
|
"""
|
|
|
|
await async_setup_component(hass, "stream", {"stream": {}})
|
|
|
|
|
|
|
|
for a_codec, expected_audio_streams in (
|
|
|
|
("aac", 1), # aac is a valid mp4 codec
|
|
|
|
("pcm_mulaw", 0), # G.711 is not a valid mp4 codec
|
|
|
|
("empty", 0), # audio stream with no packets
|
|
|
|
(None, 0), # no audio stream
|
|
|
|
):
|
2021-01-20 13:44:24 +00:00
|
|
|
record_worker_sync.reset()
|
|
|
|
|
|
|
|
# Setup demo track
|
|
|
|
source = generate_h264_video(
|
|
|
|
container_format="mov", audio_codec=a_codec
|
|
|
|
) # mov can store PCM
|
2021-02-09 03:53:28 +00:00
|
|
|
stream = create_stream(hass, source)
|
|
|
|
with patch.object(hass.config, "is_allowed_path", return_value=True):
|
|
|
|
await stream.async_record("/example/path")
|
2021-01-20 13:44:24 +00:00
|
|
|
|
2021-02-16 14:59:43 +00:00
|
|
|
segments = await record_worker_sync.get_segments()
|
|
|
|
last_segment = segments[-1]
|
2021-01-20 13:44:24 +00:00
|
|
|
|
|
|
|
result = av.open(last_segment.segment, "r", format="mp4")
|
|
|
|
|
|
|
|
assert len(result.streams.audio) == expected_audio_streams
|
|
|
|
result.close()
|
|
|
|
|
2021-02-16 14:59:43 +00:00
|
|
|
stream.stop()
|
2021-01-20 13:44:24 +00:00
|
|
|
record_worker_sync.join()
|