Refactor camera stream settings (#81663)

This commit is contained in:
uvjustin 2022-11-13 01:22:59 +08:00 committed by GitHub
parent 1fe85c9b17
commit ee910bd0e4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 226 additions and 152 deletions

View file

@ -5,7 +5,7 @@ import asyncio
import collections
from collections.abc import Awaitable, Callable, Iterable
from contextlib import suppress
from dataclasses import dataclass
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta
from enum import IntEnum
from functools import partial
@ -74,7 +74,7 @@ from .const import ( # noqa: F401
StreamType,
)
from .img_util import scale_jpeg_camera_image
from .prefs import CameraPreferences
from .prefs import CameraPreferences, DynamicStreamSettings # noqa: F401
_LOGGER = logging.getLogger(__name__)
@ -346,7 +346,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
prefs = CameraPreferences(hass)
await prefs.async_initialize()
hass.data[DATA_CAMERA_PREFS] = prefs
hass.http.register_view(CameraImageView(component))
@ -361,13 +360,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def preload_stream(_event: Event) -> None:
for camera in component.entities:
camera_prefs = prefs.get(camera.entity_id)
if not camera_prefs.preload_stream:
stream_prefs = await prefs.get_dynamic_stream_settings(camera.entity_id)
if not stream_prefs.preload_stream:
continue
stream = await camera.async_create_stream()
if not stream:
continue
stream.keepalive = True
stream.add_provider("hls")
await stream.start()
@ -524,6 +522,9 @@ class Camera(Entity):
self.hass,
source,
options=self.stream_options,
dynamic_stream_settings=await self.hass.data[
DATA_CAMERA_PREFS
].get_dynamic_stream_settings(self.entity_id),
stream_label=self.entity_id,
)
self.stream.set_update_callback(self.async_write_ha_state)
@ -861,8 +862,8 @@ async def websocket_get_prefs(
) -> None:
"""Handle request for account info."""
prefs: CameraPreferences = hass.data[DATA_CAMERA_PREFS]
camera_prefs = prefs.get(msg["entity_id"])
connection.send_result(msg["id"], camera_prefs.as_dict())
stream_prefs = await prefs.get_dynamic_stream_settings(msg["entity_id"])
connection.send_result(msg["id"], asdict(stream_prefs))
@websocket_api.websocket_command(
@ -956,12 +957,6 @@ async def _async_stream_endpoint_url(
f"{camera.entity_id} does not support play stream service"
)
# Update keepalive setting which manages idle shutdown
prefs: CameraPreferences = hass.data[DATA_CAMERA_PREFS]
camera_prefs = prefs.get(camera.entity_id)
stream.keepalive = camera_prefs.preload_stream
stream.orientation = camera_prefs.orientation
stream.add_provider(fmt)
await stream.start()
return stream.endpoint_url(fmt)

View file

@ -1,6 +1,7 @@
"""Preference management for camera component."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Final, Union, cast
from homeassistant.components.stream import Orientation
@ -16,28 +17,12 @@ STORAGE_KEY: Final = DOMAIN
STORAGE_VERSION: Final = 1
class CameraEntityPreferences:
"""Handle preferences for camera entity."""
@dataclass
class DynamicStreamSettings:
"""Stream settings which are managed and updated by the camera entity."""
def __init__(self, prefs: dict[str, bool | Orientation]) -> None:
"""Initialize prefs."""
self._prefs = prefs
def as_dict(self) -> dict[str, bool | Orientation]:
"""Return dictionary version."""
return self._prefs
@property
def preload_stream(self) -> bool:
"""Return if stream is loaded on hass start."""
return cast(bool, self._prefs.get(PREF_PRELOAD_STREAM, False))
@property
def orientation(self) -> Orientation:
"""Return the current stream orientation settings."""
return cast(
Orientation, self._prefs.get(PREF_ORIENTATION, Orientation.NO_TRANSFORM)
)
preload_stream: bool = False
orientation: Orientation = Orientation.NO_TRANSFORM
class CameraPreferences:
@ -51,15 +36,9 @@ class CameraPreferences:
self._store = Store[dict[str, dict[str, Union[bool, Orientation]]]](
hass, STORAGE_VERSION, STORAGE_KEY
)
# Local copy of the preload_stream prefs
self._prefs: dict[str, dict[str, bool | Orientation]] | None = None
async def async_initialize(self) -> None:
"""Finish initializing the preferences."""
if (prefs := await self._store.async_load()) is None:
prefs = {}
self._prefs = prefs
self._dynamic_stream_settings_by_entity_id: dict[
str, DynamicStreamSettings
] = {}
async def async_update(
self,
@ -67,20 +46,25 @@ class CameraPreferences:
*,
preload_stream: bool | UndefinedType = UNDEFINED,
orientation: Orientation | UndefinedType = UNDEFINED,
stream_options: dict[str, str] | UndefinedType = UNDEFINED,
) -> dict[str, bool | Orientation]:
"""Update camera preferences.
Also update the DynamicStreamSettings if they exist.
preload_stream is stored in a Store
orientation is stored in the Entity Registry
Returns a dict with the preferences on success.
Raises HomeAssistantError on failure.
"""
dynamic_stream_settings = self._dynamic_stream_settings_by_entity_id.get(
entity_id
)
if preload_stream is not UNDEFINED:
# Prefs already initialized.
assert self._prefs is not None
if not self._prefs.get(entity_id):
self._prefs[entity_id] = {}
self._prefs[entity_id][PREF_PRELOAD_STREAM] = preload_stream
await self._store.async_save(self._prefs)
if dynamic_stream_settings:
dynamic_stream_settings.preload_stream = preload_stream
preload_prefs = await self._store.async_load() or {}
preload_prefs[entity_id] = {PREF_PRELOAD_STREAM: preload_stream}
await self._store.async_save(preload_prefs)
if orientation is not UNDEFINED:
if (registry := er.async_get(self._hass)).async_get(entity_id):
@ -91,12 +75,26 @@ class CameraPreferences:
raise HomeAssistantError(
"Orientation is only supported on entities set up through config flows"
)
return self.get(entity_id).as_dict()
if dynamic_stream_settings:
dynamic_stream_settings.orientation = orientation
return asdict(await self.get_dynamic_stream_settings(entity_id))
def get(self, entity_id: str) -> CameraEntityPreferences:
"""Get preferences for an entity."""
# Prefs are already initialized.
assert self._prefs is not None
async def get_dynamic_stream_settings(
self, entity_id: str
) -> DynamicStreamSettings:
"""Get the DynamicStreamSettings for the entity."""
if settings := self._dynamic_stream_settings_by_entity_id.get(entity_id):
return settings
# Get preload stream setting from prefs
# Get orientation setting from entity registry
reg_entry = er.async_get(self._hass).async_get(entity_id)
er_prefs = reg_entry.options.get(DOMAIN, {}) if reg_entry else {}
return CameraEntityPreferences(self._prefs.get(entity_id, {}) | er_prefs)
preload_prefs = await self._store.async_load() or {}
settings = DynamicStreamSettings(
preload_stream=cast(
bool, preload_prefs.get(entity_id, {}).get(PREF_PRELOAD_STREAM, False)
),
orientation=er_prefs.get(PREF_ORIENTATION, Orientation.NO_TRANSFORM),
)
self._dynamic_stream_settings_by_entity_id[entity_id] = settings
return settings

View file

@ -16,7 +16,11 @@ from httpx import HTTPStatusError, RequestError, TimeoutException
import voluptuous as vol
import yarl
from homeassistant.components.camera import CAMERA_IMAGE_TIMEOUT, _async_get_image
from homeassistant.components.camera import (
CAMERA_IMAGE_TIMEOUT,
DynamicStreamSettings,
_async_get_image,
)
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.components.stream import (
CONF_RTSP_TRANSPORT,
@ -246,7 +250,13 @@ async def async_test_stream(
url = url.with_user(username).with_password(password)
stream_source = str(url)
try:
stream = create_stream(hass, stream_source, stream_options, "test_stream")
stream = create_stream(
hass,
stream_source,
stream_options,
DynamicStreamSettings(),
"test_stream",
)
hls_provider = stream.add_provider(HLS_PROVIDER)
await stream.start()
if not await hls_provider.part_recv(timeout=SOURCE_TIMEOUT):

View file

@ -137,7 +137,7 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera):
) -> bytes | None:
"""Return a still image response from the camera."""
if self.stream and self.stream.keepalive:
if self.stream and self.stream.dynamic_stream_settings.preload_stream:
return await self.stream.async_get_image(width, height)
if self.device.capabilities.snapshot:

View file

@ -25,7 +25,7 @@ import secrets
import threading
import time
from types import MappingProxyType
from typing import Any, Final, cast
from typing import TYPE_CHECKING, Any, Final, cast
import voluptuous as vol
@ -70,6 +70,9 @@ from .core import (
from .diagnostics import Diagnostics
from .hls import HlsStreamOutput, async_setup_hls
if TYPE_CHECKING:
from homeassistant.components.camera import DynamicStreamSettings
__all__ = [
"ATTR_SETTINGS",
"CONF_EXTRA_PART_WAIT_TIME",
@ -105,6 +108,7 @@ def create_stream(
hass: HomeAssistant,
stream_source: str,
options: Mapping[str, str | bool | float],
dynamic_stream_settings: DynamicStreamSettings,
stream_label: str | None = None,
) -> Stream:
"""Create a stream with the specified identfier based on the source url.
@ -156,6 +160,7 @@ def create_stream(
stream_source,
pyav_options=pyav_options,
stream_settings=stream_settings,
dynamic_stream_settings=dynamic_stream_settings,
stream_label=stream_label,
)
hass.data[DOMAIN][ATTR_STREAMS].append(stream)
@ -231,7 +236,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
part_target_duration=conf[CONF_PART_DURATION],
hls_advance_part_limit=max(int(3 / conf[CONF_PART_DURATION]), 3),
hls_part_timeout=2 * conf[CONF_PART_DURATION],
orientation=Orientation.NO_TRANSFORM,
)
else:
hass.data[DOMAIN][ATTR_SETTINGS] = STREAM_SETTINGS_NON_LL_HLS
@ -246,7 +250,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def shutdown(event: Event) -> None:
"""Stop all stream workers."""
for stream in hass.data[DOMAIN][ATTR_STREAMS]:
stream.keepalive = False
stream.dynamic_stream_settings.preload_stream = False
if awaitables := [
asyncio.create_task(stream.stop())
for stream in hass.data[DOMAIN][ATTR_STREAMS]
@ -268,6 +272,7 @@ class Stream:
source: str,
pyav_options: dict[str, str],
stream_settings: StreamSettings,
dynamic_stream_settings: DynamicStreamSettings,
stream_label: str | None = None,
) -> None:
"""Initialize a stream."""
@ -276,14 +281,16 @@ class Stream:
self.pyav_options = pyav_options
self._stream_settings = stream_settings
self._stream_label = stream_label
self.keepalive = False
self.dynamic_stream_settings = dynamic_stream_settings
self.access_token: str | None = None
self._start_stop_lock = asyncio.Lock()
self._thread: threading.Thread | None = None
self._thread_quit = threading.Event()
self._outputs: dict[str, StreamOutput] = {}
self._fast_restart_once = False
self._keyframe_converter = KeyFrameConverter(hass, stream_settings)
self._keyframe_converter = KeyFrameConverter(
hass, stream_settings, dynamic_stream_settings
)
self._available: bool = True
self._update_callback: Callable[[], None] | None = None
self._logger = (
@ -293,16 +300,6 @@ class Stream:
)
self._diagnostics = Diagnostics()
@property
def orientation(self) -> Orientation:
"""Return the current orientation setting."""
return self._stream_settings.orientation
@orientation.setter
def orientation(self, value: Orientation) -> None:
"""Set the stream orientation setting."""
self._stream_settings.orientation = value
def endpoint_url(self, fmt: str) -> str:
"""Start the stream and returns a url for the output format."""
if fmt not in self._outputs:
@ -326,7 +323,8 @@ class Stream:
async def idle_callback() -> None:
if (
not self.keepalive or fmt == RECORDER_PROVIDER
not self.dynamic_stream_settings.preload_stream
or fmt == RECORDER_PROVIDER
) and fmt in self._outputs:
await self.remove_provider(self._outputs[fmt])
self.check_idle()
@ -335,6 +333,7 @@ class Stream:
self.hass,
IdleTimer(self.hass, timeout, idle_callback),
self._stream_settings,
self.dynamic_stream_settings,
)
self._outputs[fmt] = provider
@ -413,8 +412,12 @@ class Stream:
while not self._thread_quit.wait(timeout=wait_timeout):
start_time = time.time()
self.hass.add_job(self._async_update_state, True)
self._diagnostics.set_value("keepalive", self.keepalive)
self._diagnostics.set_value("orientation", self.orientation)
self._diagnostics.set_value(
"keepalive", self.dynamic_stream_settings.preload_stream
)
self._diagnostics.set_value(
"orientation", self.dynamic_stream_settings.orientation
)
self._diagnostics.increment("start_worker")
try:
stream_worker(
@ -473,7 +476,7 @@ class Stream:
self._outputs = {}
self.access_token = None
if not self.keepalive:
if not self.dynamic_stream_settings.preload_stream:
await self._stop()
async def _stop(self) -> None:

View file

@ -29,6 +29,8 @@ from .const import (
if TYPE_CHECKING:
from av import CodecContext, Packet
from homeassistant.components.camera import DynamicStreamSettings
from . import Stream
_LOGGER = logging.getLogger(__name__)
@ -58,7 +60,6 @@ class StreamSettings:
part_target_duration: float = attr.ib()
hls_advance_part_limit: int = attr.ib()
hls_part_timeout: float = attr.ib()
orientation: Orientation = attr.ib()
STREAM_SETTINGS_NON_LL_HLS = StreamSettings(
@ -67,7 +68,6 @@ STREAM_SETTINGS_NON_LL_HLS = StreamSettings(
part_target_duration=TARGET_SEGMENT_DURATION_NON_LL_HLS,
hls_advance_part_limit=3,
hls_part_timeout=TARGET_SEGMENT_DURATION_NON_LL_HLS,
orientation=Orientation.NO_TRANSFORM,
)
@ -273,12 +273,14 @@ class StreamOutput:
hass: HomeAssistant,
idle_timer: IdleTimer,
stream_settings: StreamSettings,
dynamic_stream_settings: DynamicStreamSettings,
deque_maxlen: int | None = None,
) -> None:
"""Initialize a stream output."""
self._hass = hass
self.idle_timer = idle_timer
self.stream_settings = stream_settings
self.dynamic_stream_settings = dynamic_stream_settings
self._event = asyncio.Event()
self._part_event = asyncio.Event()
self._segments: deque[Segment] = deque(maxlen=deque_maxlen)
@ -427,7 +429,12 @@ class KeyFrameConverter:
If unsuccessful, get_image will return the previous image
"""
def __init__(self, hass: HomeAssistant, stream_settings: StreamSettings) -> None:
def __init__(
self,
hass: HomeAssistant,
stream_settings: StreamSettings,
dynamic_stream_settings: DynamicStreamSettings,
) -> None:
"""Initialize."""
# Keep import here so that we can import stream integration without installing reqs
@ -441,6 +448,7 @@ class KeyFrameConverter:
self._lock = asyncio.Lock()
self._codec_context: CodecContext | None = None
self._stream_settings = stream_settings
self._dynamic_stream_settings = dynamic_stream_settings
def create_codec_context(self, codec_context: CodecContext) -> None:
"""
@ -498,12 +506,13 @@ class KeyFrameConverter:
if frames:
frame = frames[0]
if width and height:
if self._stream_settings.orientation >= 5:
if self._dynamic_stream_settings.orientation >= 5:
frame = frame.reformat(width=height, height=width)
else:
frame = frame.reformat(width=width, height=height)
bgr_array = self.transform_image(
frame.to_ndarray(format="bgr24"), self._stream_settings.orientation
frame.to_ndarray(format="bgr24"),
self._dynamic_stream_settings.orientation,
)
self._image = bytes(self._turbojpeg.encode(bgr_array))

View file

@ -27,6 +27,8 @@ from .core import (
from .fmp4utils import get_codec_string, transform_init
if TYPE_CHECKING:
from homeassistant.components.camera import DynamicStreamSettings
from . import Stream
@ -50,9 +52,16 @@ class HlsStreamOutput(StreamOutput):
hass: HomeAssistant,
idle_timer: IdleTimer,
stream_settings: StreamSettings,
dynamic_stream_settings: DynamicStreamSettings,
) -> None:
"""Initialize HLS output."""
super().__init__(hass, idle_timer, stream_settings, deque_maxlen=MAX_SEGMENTS)
super().__init__(
hass,
idle_timer,
stream_settings,
dynamic_stream_settings,
deque_maxlen=MAX_SEGMENTS,
)
self._target_duration = stream_settings.min_segment_duration
@property
@ -339,7 +348,7 @@ class HlsInitView(StreamView):
if not (segments := track.get_segments()) or not (body := segments[0].init):
return web.HTTPNotFound()
return web.Response(
body=transform_init(body, stream.orientation),
body=transform_init(body, stream.dynamic_stream_settings.orientation),
headers={"Content-Type": "video/mp4"},
)

View file

@ -21,6 +21,8 @@ from .fmp4utils import read_init, transform_init
if TYPE_CHECKING:
import deque
from homeassistant.components.camera import DynamicStreamSettings
_LOGGER = logging.getLogger(__name__)
@ -38,9 +40,10 @@ class RecorderOutput(StreamOutput):
hass: HomeAssistant,
idle_timer: IdleTimer,
stream_settings: StreamSettings,
dynamic_stream_settings: DynamicStreamSettings,
) -> None:
"""Initialize recorder output."""
super().__init__(hass, idle_timer, stream_settings)
super().__init__(hass, idle_timer, stream_settings, dynamic_stream_settings)
self.video_path: str
@property
@ -154,7 +157,7 @@ class RecorderOutput(StreamOutput):
video_path, mode="wb"
) as out_file:
init = transform_init(
read_init(in_file), self.stream_settings.orientation
read_init(in_file), self.dynamic_stream_settings.orientation
)
out_file.write(init)
in_file.seek(len(init))

View file

@ -12,7 +12,6 @@ from homeassistant.components.camera.const import (
PREF_ORIENTATION,
PREF_PRELOAD_STREAM,
)
from homeassistant.components.camera.prefs import CameraEntityPreferences
from homeassistant.components.websocket_api.const import TYPE_RESULT
from homeassistant.config import async_process_ha_core_config
from homeassistant.const import (
@ -302,8 +301,9 @@ async def test_websocket_update_preload_prefs(hass, hass_ws_client, mock_camera)
)
msg = await client.receive_json()
# There should be no preferences
assert not msg["result"]
# The default prefs should be returned. Preload stream should be False
assert msg["success"]
assert msg["result"][PREF_PRELOAD_STREAM] is False
# Update the preference
await client.send_json(
@ -421,12 +421,12 @@ async def test_handle_play_stream_service(hass, mock_camera, mock_stream):
async def test_no_preload_stream(hass, mock_stream):
"""Test camera preload preference."""
demo_prefs = CameraEntityPreferences({PREF_PRELOAD_STREAM: False})
demo_settings = camera.DynamicStreamSettings()
with patch(
"homeassistant.components.camera.Stream.endpoint_url",
) as mock_request_stream, patch(
"homeassistant.components.camera.prefs.CameraPreferences.get",
return_value=demo_prefs,
"homeassistant.components.camera.prefs.CameraPreferences.get_dynamic_stream_settings",
return_value=demo_settings,
), patch(
"homeassistant.components.demo.camera.DemoCamera.stream_source",
new_callable=PropertyMock,
@ -440,12 +440,12 @@ async def test_no_preload_stream(hass, mock_stream):
async def test_preload_stream(hass, mock_stream):
"""Test camera preload preference."""
demo_prefs = CameraEntityPreferences({PREF_PRELOAD_STREAM: True})
demo_settings = camera.DynamicStreamSettings(preload_stream=True)
with patch(
"homeassistant.components.camera.create_stream"
) as mock_create_stream, patch(
"homeassistant.components.camera.prefs.CameraPreferences.get",
return_value=demo_prefs,
"homeassistant.components.camera.prefs.CameraPreferences.get_dynamic_stream_settings",
return_value=demo_settings,
), patch(
"homeassistant.components.demo.camera.DemoCamera.stream_source",
return_value="http://example.com",

View file

@ -8,7 +8,8 @@ import io
import av
import numpy as np
from homeassistant.components.stream.core import Segment
from homeassistant.components.camera import DynamicStreamSettings
from homeassistant.components.stream.core import Orientation, Segment
from homeassistant.components.stream.fmp4utils import (
TRANSFORM_MATRIX_TOP,
XYW_ROW,
@ -16,8 +17,8 @@ from homeassistant.components.stream.fmp4utils import (
)
FAKE_TIME = datetime.utcnow()
# Segment with defaults filled in for use in tests
# Segment with defaults filled in for use in tests
DefaultSegment = partial(
Segment,
init=None,
@ -157,7 +158,7 @@ def remux_with_audio(source, container_format, audio_codec):
return output
def assert_mp4_has_transform_matrix(mp4: bytes, orientation: int):
def assert_mp4_has_transform_matrix(mp4: bytes, orientation: Orientation):
"""Assert that the mp4 (or init) has the proper transformation matrix."""
# Find moov
moov_location = next(find_box(mp4, b"moov"))
@ -170,3 +171,8 @@ def assert_mp4_has_transform_matrix(mp4: bytes, orientation: int):
mp4[tkhd_location + tkhd_length - 44 : tkhd_location + tkhd_length - 8]
== TRANSFORM_MATRIX_TOP[orientation] + XYW_ROW
)
def dynamic_stream_settings():
"""Create new dynamic stream settings."""
return DynamicStreamSettings()

View file

@ -16,7 +16,7 @@ from homeassistant.components.stream.const import (
MAX_SEGMENTS,
NUM_PLAYLIST_SEGMENTS,
)
from homeassistant.components.stream.core import Part
from homeassistant.components.stream.core import Orientation, Part
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
@ -24,6 +24,7 @@ from .common import (
FAKE_TIME,
DefaultSegment as Segment,
assert_mp4_has_transform_matrix,
dynamic_stream_settings,
)
from tests.common import async_fire_time_changed
@ -145,7 +146,7 @@ async def test_hls_stream(
stream_worker_sync.pause()
# Setup demo HLS track
stream = create_stream(hass, h264_video, {})
stream = create_stream(hass, h264_video, {}, dynamic_stream_settings())
# Request stream
stream.add_provider(HLS_PROVIDER)
@ -185,7 +186,7 @@ async def test_hls_stream(
assert stream.get_diagnostics() == {
"container_format": "mov,mp4,m4a,3gp,3g2,mj2",
"keepalive": False,
"orientation": 1,
"orientation": Orientation.NO_TRANSFORM,
"start_worker": 1,
"video_codec": "h264",
"worker_error": 1,
@ -199,7 +200,7 @@ async def test_stream_timeout(
stream_worker_sync.pause()
# Setup demo HLS track
stream = create_stream(hass, h264_video, {})
stream = create_stream(hass, h264_video, {}, dynamic_stream_settings())
available_states = []
@ -252,7 +253,7 @@ async def test_stream_timeout_after_stop(
stream_worker_sync.pause()
# Setup demo HLS track
stream = create_stream(hass, h264_video, {})
stream = create_stream(hass, h264_video, {}, dynamic_stream_settings())
# Request stream
stream.add_provider(HLS_PROVIDER)
@ -272,7 +273,7 @@ async def test_stream_retries(hass, setup_component, should_retry):
"""Test hls stream is retried on failure."""
# Setup demo HLS track
source = "test_stream_keepalive_source"
stream = create_stream(hass, source, {})
stream = create_stream(hass, source, {}, dynamic_stream_settings())
track = stream.add_provider(HLS_PROVIDER)
track.num_segments = 2
@ -320,7 +321,7 @@ async def test_stream_retries(hass, setup_component, should_retry):
async def test_hls_playlist_view_no_output(hass, setup_component, hls_stream):
"""Test rendering the hls playlist with no output segments."""
stream = create_stream(hass, STREAM_SOURCE, {})
stream = create_stream(hass, STREAM_SOURCE, {}, dynamic_stream_settings())
stream.add_provider(HLS_PROVIDER)
hls_client = await hls_stream(stream)
@ -332,7 +333,7 @@ async def test_hls_playlist_view_no_output(hass, setup_component, hls_stream):
async def test_hls_playlist_view(hass, setup_component, hls_stream, stream_worker_sync):
"""Test rendering the hls playlist with 1 and 2 output segments."""
stream = create_stream(hass, STREAM_SOURCE, {})
stream = create_stream(hass, STREAM_SOURCE, {}, dynamic_stream_settings())
stream_worker_sync.pause()
hls = stream.add_provider(HLS_PROVIDER)
for i in range(2):
@ -363,7 +364,7 @@ async def test_hls_playlist_view(hass, setup_component, hls_stream, stream_worke
async def test_hls_max_segments(hass, setup_component, hls_stream, stream_worker_sync):
"""Test rendering the hls playlist with more segments than the segment deque can hold."""
stream = create_stream(hass, STREAM_SOURCE, {})
stream = create_stream(hass, STREAM_SOURCE, {}, dynamic_stream_settings())
stream_worker_sync.pause()
hls = stream.add_provider(HLS_PROVIDER)
@ -415,7 +416,7 @@ async def test_hls_playlist_view_discontinuity(
):
"""Test a discontinuity across segments in the stream with 3 segments."""
stream = create_stream(hass, STREAM_SOURCE, {})
stream = create_stream(hass, STREAM_SOURCE, {}, dynamic_stream_settings())
stream_worker_sync.pause()
hls = stream.add_provider(HLS_PROVIDER)
@ -452,7 +453,7 @@ async def test_hls_max_segments_discontinuity(
hass, setup_component, hls_stream, stream_worker_sync
):
"""Test a discontinuity with more segments than the segment deque can hold."""
stream = create_stream(hass, STREAM_SOURCE, {})
stream = create_stream(hass, STREAM_SOURCE, {}, dynamic_stream_settings())
stream_worker_sync.pause()
hls = stream.add_provider(HLS_PROVIDER)
@ -495,7 +496,7 @@ async def test_remove_incomplete_segment_on_exit(
hass, setup_component, stream_worker_sync
):
"""Test that the incomplete segment gets removed when the worker thread quits."""
stream = create_stream(hass, STREAM_SOURCE, {})
stream = create_stream(hass, STREAM_SOURCE, {}, dynamic_stream_settings())
stream_worker_sync.pause()
await stream.start()
hls = stream.add_provider(HLS_PROVIDER)
@ -536,7 +537,7 @@ async def test_hls_stream_rotate(
stream_worker_sync.pause()
# Setup demo HLS track
stream = create_stream(hass, h264_video, {})
stream = create_stream(hass, h264_video, {}, dynamic_stream_settings())
# Request stream
stream.add_provider(HLS_PROVIDER)
@ -549,14 +550,14 @@ async def test_hls_stream_rotate(
assert master_playlist_response.status == HTTPStatus.OK
# Fetch rotated init
stream.orientation = 6
stream.dynamic_stream_settings.orientation = Orientation.ROTATE_LEFT
init_response = await hls_client.get("/init.mp4")
assert init_response.status == HTTPStatus.OK
init = await init_response.read()
stream_worker_sync.resume()
assert_mp4_has_transform_matrix(init, stream.orientation)
assert_mp4_has_transform_matrix(init, stream.dynamic_stream_settings.orientation)
# Stop stream, if it hasn't quit already
await stream.stop()

View file

@ -22,7 +22,12 @@ from homeassistant.components.stream.const import (
from homeassistant.components.stream.core import Part
from homeassistant.setup import async_setup_component
from .common import FAKE_TIME, DefaultSegment as Segment, generate_h264_video
from .common import (
FAKE_TIME,
DefaultSegment as Segment,
dynamic_stream_settings,
generate_h264_video,
)
from .test_hls import STREAM_SOURCE, HlsClient, make_playlist
SEGMENT_DURATION = 6
@ -135,7 +140,7 @@ async def test_ll_hls_stream(hass, hls_stream, stream_worker_sync):
num_playlist_segments = 3
# Setup demo HLS track
source = generate_h264_video(duration=num_playlist_segments * SEGMENT_DURATION + 2)
stream = create_stream(hass, source, {})
stream = create_stream(hass, source, {}, dynamic_stream_settings())
# Request stream
stream.add_provider(HLS_PROVIDER)
@ -259,7 +264,7 @@ async def test_ll_hls_playlist_view(hass, hls_stream, stream_worker_sync):
},
)
stream = create_stream(hass, STREAM_SOURCE, {})
stream = create_stream(hass, STREAM_SOURCE, {}, dynamic_stream_settings())
stream_worker_sync.pause()
hls = stream.add_provider(HLS_PROVIDER)
@ -328,7 +333,7 @@ async def test_ll_hls_msn(hass, hls_stream, stream_worker_sync, hls_sync):
},
)
stream = create_stream(hass, STREAM_SOURCE, {})
stream = create_stream(hass, STREAM_SOURCE, {}, dynamic_stream_settings())
stream_worker_sync.pause()
hls = stream.add_provider(HLS_PROVIDER)
@ -393,7 +398,7 @@ async def test_ll_hls_playlist_bad_msn_part(hass, hls_stream, stream_worker_sync
},
)
stream = create_stream(hass, STREAM_SOURCE, {})
stream = create_stream(hass, STREAM_SOURCE, {}, dynamic_stream_settings())
stream_worker_sync.pause()
hls = stream.add_provider(HLS_PROVIDER)
@ -462,7 +467,7 @@ async def test_ll_hls_playlist_rollover_part(
},
)
stream = create_stream(hass, STREAM_SOURCE, {})
stream = create_stream(hass, STREAM_SOURCE, {}, dynamic_stream_settings())
stream_worker_sync.pause()
hls = stream.add_provider(HLS_PROVIDER)
@ -541,7 +546,7 @@ async def test_ll_hls_playlist_msn_part(hass, hls_stream, stream_worker_sync, hl
},
)
stream = create_stream(hass, STREAM_SOURCE, {})
stream = create_stream(hass, STREAM_SOURCE, {}, dynamic_stream_settings())
stream_worker_sync.pause()
hls = stream.add_provider(HLS_PROVIDER)
@ -607,7 +612,7 @@ async def test_get_part_segments(hass, hls_stream, stream_worker_sync, hls_sync)
},
)
stream = create_stream(hass, STREAM_SOURCE, {})
stream = create_stream(hass, STREAM_SOURCE, {}, dynamic_stream_settings())
stream_worker_sync.pause()
hls = stream.add_provider(HLS_PROVIDER)

View file

@ -14,7 +14,7 @@ from homeassistant.components.stream.const import (
OUTPUT_IDLE_TIMEOUT,
RECORDER_PROVIDER,
)
from homeassistant.components.stream.core import Part
from homeassistant.components.stream.core import Orientation, Part
from homeassistant.components.stream.fmp4utils import find_box
from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component
@ -23,6 +23,7 @@ import homeassistant.util.dt as dt_util
from .common import (
DefaultSegment as Segment,
assert_mp4_has_transform_matrix,
dynamic_stream_settings,
generate_h264_video,
remux_with_audio,
)
@ -56,7 +57,7 @@ async def test_record_stream(hass, filename, h264_video):
worker_finished.set()
with patch("homeassistant.components.stream.Stream", wraps=MockStream):
stream = create_stream(hass, h264_video, {})
stream = create_stream(hass, h264_video, {}, dynamic_stream_settings())
with patch.object(hass.config, "is_allowed_path", return_value=True):
make_recording = hass.async_create_task(stream.async_record(filename))
@ -79,7 +80,7 @@ async def test_record_stream(hass, filename, h264_video):
async def test_record_lookback(hass, filename, h264_video):
"""Exercise record with lookback."""
stream = create_stream(hass, h264_video, {})
stream = create_stream(hass, h264_video, {}, dynamic_stream_settings())
# Start an HLS feed to enable lookback
stream.add_provider(HLS_PROVIDER)
@ -96,7 +97,7 @@ async def test_record_lookback(hass, filename, h264_video):
async def test_record_path_not_allowed(hass, h264_video):
"""Test where the output path is not allowed by home assistant configuration."""
stream = create_stream(hass, h264_video, {})
stream = create_stream(hass, h264_video, {}, dynamic_stream_settings())
with patch.object(
hass.config, "is_allowed_path", return_value=False
), pytest.raises(HomeAssistantError):
@ -146,7 +147,7 @@ async def test_recorder_discontinuity(hass, filename, h264_video):
with patch.object(hass.config, "is_allowed_path", return_value=True), patch(
"homeassistant.components.stream.Stream", wraps=MockStream
), patch("homeassistant.components.stream.recorder.RecorderOutput.recv"):
stream = create_stream(hass, "blank", {})
stream = create_stream(hass, "blank", {}, dynamic_stream_settings())
make_recording = hass.async_create_task(stream.async_record(filename))
await provider_ready.wait()
@ -166,7 +167,7 @@ async def test_recorder_discontinuity(hass, filename, h264_video):
async def test_recorder_no_segments(hass, filename):
"""Test recorder behavior with a stream failure which causes no segments."""
stream = create_stream(hass, BytesIO(), {})
stream = create_stream(hass, BytesIO(), {}, dynamic_stream_settings())
# Run
with patch.object(hass.config, "is_allowed_path", return_value=True):
@ -219,7 +220,7 @@ async def test_record_stream_audio(
worker_finished.set()
with patch("homeassistant.components.stream.Stream", wraps=MockStream):
stream = create_stream(hass, source, {})
stream = create_stream(hass, source, {}, dynamic_stream_settings())
with patch.object(hass.config, "is_allowed_path", return_value=True):
make_recording = hass.async_create_task(stream.async_record(filename))
@ -252,7 +253,9 @@ async def test_record_stream_audio(
async def test_recorder_log(hass, filename, caplog):
"""Test starting a stream to record logs the url without username and password."""
stream = create_stream(hass, "https://abcd:efgh@foo.bar", {})
stream = create_stream(
hass, "https://abcd:efgh@foo.bar", {}, dynamic_stream_settings()
)
with patch.object(hass.config, "is_allowed_path", return_value=True):
await stream.async_record(filename)
assert "https://abcd:efgh@foo.bar" not in caplog.text
@ -273,8 +276,8 @@ async def test_record_stream_rotate(hass, filename, h264_video):
worker_finished.set()
with patch("homeassistant.components.stream.Stream", wraps=MockStream):
stream = create_stream(hass, h264_video, {})
stream.orientation = 8
stream = create_stream(hass, h264_video, {}, dynamic_stream_settings())
stream.dynamic_stream_settings.orientation = Orientation.ROTATE_RIGHT
with patch.object(hass.config, "is_allowed_path", return_value=True):
make_recording = hass.async_create_task(stream.async_record(filename))
@ -293,4 +296,6 @@ async def test_record_stream_rotate(hass, filename, h264_video):
# Assert
assert os.path.exists(filename)
with open(filename, "rb") as rotated_mp4:
assert_mp4_has_transform_matrix(rotated_mp4.read(), stream.orientation)
assert_mp4_has_transform_matrix(
rotated_mp4.read(), stream.dynamic_stream_settings.orientation
)

View file

@ -39,7 +39,7 @@ from homeassistant.components.stream.const import (
SEGMENT_DURATION_ADJUSTER,
TARGET_SEGMENT_DURATION_NON_LL_HLS,
)
from homeassistant.components.stream.core import StreamSettings
from homeassistant.components.stream.core import Orientation, StreamSettings
from homeassistant.components.stream.worker import (
StreamEndedError,
StreamState,
@ -48,7 +48,7 @@ from homeassistant.components.stream.worker import (
)
from homeassistant.setup import async_setup_component
from .common import generate_h264_video, generate_h265_video
from .common import dynamic_stream_settings, generate_h264_video, generate_h265_video
from .test_ll_hls import TEST_PART_DURATION
from tests.components.camera.common import EMPTY_8_6_JPEG, mock_turbo_jpeg
@ -90,7 +90,6 @@ def mock_stream_settings(hass):
part_target_duration=TARGET_SEGMENT_DURATION_NON_LL_HLS,
hls_advance_part_limit=3,
hls_part_timeout=TARGET_SEGMENT_DURATION_NON_LL_HLS,
orientation=1,
)
}
@ -287,7 +286,7 @@ def run_worker(hass, stream, stream_source, stream_settings=None):
{},
stream_settings or hass.data[DOMAIN][ATTR_SETTINGS],
stream_state,
KeyFrameConverter(hass, 1),
KeyFrameConverter(hass, stream_settings, dynamic_stream_settings()),
threading.Event(),
)
@ -295,7 +294,11 @@ def run_worker(hass, stream, stream_source, stream_settings=None):
async def async_decode_stream(hass, packets, py_av=None, stream_settings=None):
"""Start a stream worker that decodes incoming stream packets into output segments."""
stream = Stream(
hass, STREAM_SOURCE, {}, stream_settings or hass.data[DOMAIN][ATTR_SETTINGS]
hass,
STREAM_SOURCE,
{},
stream_settings or hass.data[DOMAIN][ATTR_SETTINGS],
dynamic_stream_settings(),
)
stream.add_provider(HLS_PROVIDER)
@ -322,7 +325,13 @@ async def async_decode_stream(hass, packets, py_av=None, stream_settings=None):
async def test_stream_open_fails(hass):
"""Test failure on stream open."""
stream = Stream(hass, STREAM_SOURCE, {}, hass.data[DOMAIN][ATTR_SETTINGS])
stream = Stream(
hass,
STREAM_SOURCE,
{},
hass.data[DOMAIN][ATTR_SETTINGS],
dynamic_stream_settings(),
)
stream.add_provider(HLS_PROVIDER)
with patch("av.open") as av_open, pytest.raises(StreamWorkerError):
av_open.side_effect = av.error.InvalidDataError(-2, "error")
@ -636,7 +645,13 @@ async def test_stream_stopped_while_decoding(hass):
worker_open = threading.Event()
worker_wake = threading.Event()
stream = Stream(hass, STREAM_SOURCE, {}, hass.data[DOMAIN][ATTR_SETTINGS])
stream = Stream(
hass,
STREAM_SOURCE,
{},
hass.data[DOMAIN][ATTR_SETTINGS],
dynamic_stream_settings(),
)
stream.add_provider(HLS_PROVIDER)
py_av = MockPyAv()
@ -666,7 +681,13 @@ async def test_update_stream_source(hass):
worker_open = threading.Event()
worker_wake = threading.Event()
stream = Stream(hass, STREAM_SOURCE, {}, hass.data[DOMAIN][ATTR_SETTINGS])
stream = Stream(
hass,
STREAM_SOURCE,
{},
hass.data[DOMAIN][ATTR_SETTINGS],
dynamic_stream_settings(),
)
stream.add_provider(HLS_PROVIDER)
# Note that retries are disabled by default in tests, however the stream is "restarted" when
# the stream source is updated.
@ -709,7 +730,11 @@ async def test_update_stream_source(hass):
async def test_worker_log(hass, caplog):
"""Test that the worker logs the url without username and password."""
stream = Stream(
hass, "https://abcd:efgh@foo.bar", {}, hass.data[DOMAIN][ATTR_SETTINGS]
hass,
"https://abcd:efgh@foo.bar",
{},
hass.data[DOMAIN][ATTR_SETTINGS],
dynamic_stream_settings(),
)
stream.add_provider(HLS_PROVIDER)
@ -764,7 +789,9 @@ async def test_durations(hass, worker_finished_stream):
worker_finished, mock_stream = worker_finished_stream
with patch("homeassistant.components.stream.Stream", wraps=mock_stream):
stream = create_stream(hass, source, {}, stream_label="camera")
stream = create_stream(
hass, source, {}, dynamic_stream_settings(), stream_label="camera"
)
recorder_output = stream.add_provider(RECORDER_PROVIDER, timeout=30)
await stream.start()
@ -839,7 +866,9 @@ async def test_has_keyframe(hass, h264_video, worker_finished_stream):
worker_finished, mock_stream = worker_finished_stream
with patch("homeassistant.components.stream.Stream", wraps=mock_stream):
stream = create_stream(hass, h264_video, {}, stream_label="camera")
stream = create_stream(
hass, h264_video, {}, dynamic_stream_settings(), stream_label="camera"
)
recorder_output = stream.add_provider(RECORDER_PROVIDER, timeout=30)
await stream.start()
@ -880,7 +909,9 @@ async def test_h265_video_is_hvc1(hass, worker_finished_stream):
worker_finished, mock_stream = worker_finished_stream
with patch("homeassistant.components.stream.Stream", wraps=mock_stream):
stream = create_stream(hass, source, {}, stream_label="camera")
stream = create_stream(
hass, source, {}, dynamic_stream_settings(), stream_label="camera"
)
recorder_output = stream.add_provider(RECORDER_PROVIDER, timeout=30)
await stream.start()
@ -900,7 +931,7 @@ async def test_h265_video_is_hvc1(hass, worker_finished_stream):
assert stream.get_diagnostics() == {
"container_format": "mov,mp4,m4a,3gp,3g2,mj2",
"keepalive": False,
"orientation": 1,
"orientation": Orientation.NO_TRANSFORM,
"start_worker": 1,
"video_codec": "hevc",
"worker_error": 1,
@ -916,7 +947,7 @@ async def test_get_image(hass, h264_video, filename):
"homeassistant.components.camera.img_util.TurboJPEGSingleton"
) as mock_turbo_jpeg_singleton:
mock_turbo_jpeg_singleton.instance.return_value = mock_turbo_jpeg()
stream = create_stream(hass, h264_video, {})
stream = create_stream(hass, h264_video, {}, dynamic_stream_settings())
with patch.object(hass.config, "is_allowed_path", return_value=True):
make_recording = hass.async_create_task(stream.async_record(filename))
@ -937,7 +968,6 @@ async def test_worker_disable_ll_hls(hass):
part_target_duration=TARGET_SEGMENT_DURATION_NON_LL_HLS,
hls_advance_part_limit=3,
hls_part_timeout=TARGET_SEGMENT_DURATION_NON_LL_HLS,
orientation=1,
)
py_av = MockPyAv()
py_av.container.format.name = "hls"
@ -959,9 +989,9 @@ async def test_get_image_rotated(hass, h264_video, filename):
"homeassistant.components.camera.img_util.TurboJPEGSingleton"
) as mock_turbo_jpeg_singleton:
mock_turbo_jpeg_singleton.instance.return_value = mock_turbo_jpeg()
for orientation in (1, 8):
stream = create_stream(hass, h264_video, {})
stream._stream_settings.orientation = orientation
for orientation in (Orientation.NO_TRANSFORM, Orientation.ROTATE_RIGHT):
stream = create_stream(hass, h264_video, {}, dynamic_stream_settings())
stream.dynamic_stream_settings.orientation = orientation
with patch.object(hass.config, "is_allowed_path", return_value=True):
make_recording = hass.async_create_task(stream.async_record(filename))