diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index da88dc49a5b..82d981fc0cc 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -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) diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py index fac93df474e..0a8785457e8 100644 --- a/homeassistant/components/camera/prefs.py +++ b/homeassistant/components/camera/prefs.py @@ -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 diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 09f52705734..6fa01ba369e 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -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): diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 9a8535f2599..11699731b2f 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -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: diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 01cd3c2962c..7e6f663fc28 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -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: diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 31654f7d0db..a21a9f17d96 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -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)) diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py index e8920abcaa6..cddb4413ed8 100644 --- a/homeassistant/components/stream/hls.py +++ b/homeassistant/components/stream/hls.py @@ -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"}, ) diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index e917292251a..fffbd489757 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -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)) diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 9bf35ec55fa..3a78740684a 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -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", diff --git a/tests/components/stream/common.py b/tests/components/stream/common.py index de5b2c234eb..ff98d90ea8d 100644 --- a/tests/components/stream/common.py +++ b/tests/components/stream/common.py @@ -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() diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index e9da793369f..cd4f5796938 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -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() diff --git a/tests/components/stream/test_ll_hls.py b/tests/components/stream/test_ll_hls.py index baad3043547..448c3593d68 100644 --- a/tests/components/stream/test_ll_hls.py +++ b/tests/components/stream/test_ll_hls.py @@ -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) diff --git a/tests/components/stream/test_recorder.py b/tests/components/stream/test_recorder.py index c07675c7712..1e941dd5d81 100644 --- a/tests/components/stream/test_recorder.py +++ b/tests/components/stream/test_recorder.py @@ -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 + ) diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index e77b062fa9c..e9f5769ddf1 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -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))