From 5ff656d9ff4f928b34dc28edadd0f4495f89f275 Mon Sep 17 00:00:00 2001 From: Justin Wong <46082645+uvjustin@users.noreply.github.com> Date: Sun, 28 Aug 2022 09:36:50 +0000 Subject: [PATCH 01/15] Add orientation transforms to stream --- homeassistant/components/stream/__init__.py | 8 +- homeassistant/components/stream/const.py | 1 + homeassistant/components/stream/core.py | 37 +++++++- homeassistant/components/stream/fmp4utils.py | 50 +++++++++- homeassistant/components/stream/recorder.py | 23 ++++- homeassistant/components/stream/worker.py | 2 +- tests/components/stream/test_worker.py | 98 +++++++++++++++++++- 7 files changed, 204 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 354f9a77672f37..5f0165c61a1dcc 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -41,6 +41,7 @@ ATTR_STREAMS, CONF_EXTRA_PART_WAIT_TIME, CONF_LL_HLS, + CONF_ORIENTATION, CONF_PART_DURATION, CONF_RTSP_TRANSPORT, CONF_SEGMENT_DURATION, @@ -124,6 +125,7 @@ def convert_stream_options( except vol.Invalid as exc: raise HomeAssistantError("Invalid stream options") from exc + stream_settings.orientation = stream_options.get(CONF_ORIENTATION, 1) if extra_wait_time := stream_options.get(CONF_EXTRA_PART_WAIT_TIME): stream_settings.hls_part_timeout += extra_wait_time if rtsp_transport := stream_options.get(CONF_RTSP_TRANSPORT): @@ -229,6 +231,7 @@ 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=1, ) else: hass.data[DOMAIN][ATTR_SETTINGS] = STREAM_SETTINGS_NON_LL_HLS @@ -280,7 +283,7 @@ def __init__( self._thread_quit = threading.Event() self._outputs: dict[str, StreamOutput] = {} self._fast_restart_once = False - self._keyframe_converter = KeyFrameConverter(hass) + self._keyframe_converter = KeyFrameConverter(hass, stream_settings.orientation) self._available: bool = True self._update_callback: Callable[[], None] | None = None self._logger = ( @@ -549,5 +552,8 @@ def _should_retry() -> bool: vol.Optional(CONF_RTSP_TRANSPORT): vol.In(RTSP_TRANSPORTS), vol.Optional(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): bool, vol.Optional(CONF_EXTRA_PART_WAIT_TIME): cv.positive_float, + # See EXIF orientations: + # https://www.cipa.jp/std/documents/e/DC-X008-Translation-2019-E.pdf + vol.Optional(CONF_ORIENTATION): vol.All(int, vol.Range(min=1, max=8)), } ) diff --git a/homeassistant/components/stream/const.py b/homeassistant/components/stream/const.py index 35af633435eb1c..8d55b01b17a480 100644 --- a/homeassistant/components/stream/const.py +++ b/homeassistant/components/stream/const.py @@ -54,3 +54,4 @@ } CONF_USE_WALLCLOCK_AS_TIMESTAMPS = "use_wallclock_as_timestamps" CONF_EXTRA_PART_WAIT_TIME = "extra_part_wait_time" +CONF_ORIENTATION = "orientation" diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 8c456af91aa7d1..9a047dca2d3f65 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -11,6 +11,7 @@ from aiohttp import web import async_timeout import attr +import numpy as np from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback @@ -43,6 +44,7 @@ class StreamSettings: part_target_duration: float = attr.ib() hls_advance_part_limit: int = attr.ib() hls_part_timeout: float = attr.ib() + orientation: int = attr.ib() STREAM_SETTINGS_NON_LL_HLS = StreamSettings( @@ -51,6 +53,7 @@ class 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=1, ) @@ -397,7 +400,7 @@ class KeyFrameConverter: If unsuccessful, get_image will return the previous image """ - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, orientation: int) -> None: """Initialize.""" # Keep import here so that we can import stream integration without installing reqs @@ -410,6 +413,7 @@ def __init__(self, hass: HomeAssistant) -> None: self._turbojpeg = TurboJPEGSingleton.instance() self._lock = asyncio.Lock() self._codec_context: CodecContext | None = None + self._orientation = orientation def create_codec_context(self, codec_context: CodecContext) -> None: """ @@ -430,6 +434,28 @@ def create_codec_context(self, codec_context: CodecContext) -> None: self._codec_context.skip_frame = "NONKEY" self._codec_context.thread_type = "NONE" + @staticmethod + def transform_image(image: np.ndarray, orientation: int) -> np.ndarray: + """Transform image to a given orientation. + + Adapted from https://github.com/lilohuang/PyTurboJPEG. + """ + if orientation == 1: + return image + if orientation == 2: + return np.fliplr(image).copy() + if orientation == 3: + return np.rot90(image, 2).copy() + if orientation == 4: + return np.flipud(image).copy() + if orientation == 5: + return np.rot90(np.flipud(image), -1).copy() + if orientation == 6: + return np.rot90(image).copy() + if orientation == 7: + return np.rot90(np.flipud(image)).copy() + return np.rot90(image, -1).copy() + def _generate_image(self, width: int | None, height: int | None) -> None: """ Generate the keyframe image. @@ -462,8 +488,13 @@ def _generate_image(self, width: int | None, height: int | None) -> None: if frames: frame = frames[0] if width and height: - frame = frame.reformat(width=width, height=height) - bgr_array = frame.to_ndarray(format="bgr24") + if self._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._orientation + ) self._image = bytes(self._turbojpeg.encode(bgr_array)) async def async_get_image( diff --git a/homeassistant/components/stream/fmp4utils.py b/homeassistant/components/stream/fmp4utils.py index 313f5632841553..341bba9300833b 100644 --- a/homeassistant/components/stream/fmp4utils.py +++ b/homeassistant/components/stream/fmp4utils.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from io import BytesIO + from io import BufferedIOBase def find_box( @@ -141,9 +141,55 @@ def get_codec_string(mp4_bytes: bytes) -> str: return ",".join(codecs) -def read_init(bytes_io: BytesIO) -> bytes: +def read_init(bytes_io: BufferedIOBase, orientation: int) -> bytes: """Read the init from a mp4 file.""" bytes_io.seek(24) moov_len = int.from_bytes(bytes_io.read(4), byteorder="big") bytes_io.seek(0) + if orientation >= 2: + return transform_init(bytes_io.read(24 + moov_len), orientation) return bytes_io.read(24 + moov_len) + + +ZERO32 = b"\x00\x00\x00\x00" +ONE32 = b"\x00\x01\x00\x00" +NEGONE32 = b"\xFF\xFF\x00\x00" +XYW_ROW = ZERO32 + ZERO32 + b"\x40\x00\x00\x00" +ROTATE_RIGHT = (ZERO32 + ONE32 + ZERO32) + (NEGONE32 + ZERO32 + ZERO32) +ROTATE_LEFT = (ZERO32 + NEGONE32 + ZERO32) + (ONE32 + ZERO32 + ZERO32) +ROTATE_180 = (NEGONE32 + ZERO32 + ZERO32) + (ZERO32 + NEGONE32 + ZERO32) +MIRROR = (NEGONE32 + ZERO32 + ZERO32) + (ZERO32 + ONE32 + ZERO32) +FLIP = (ONE32 + ZERO32 + ZERO32) + (ZERO32 + NEGONE32 + ZERO32) +# The two below do not seem to get applied properly +ROTATE_LEFT_FLIP = (ZERO32 + NEGONE32 + ZERO32) + (NEGONE32 + ZERO32 + ZERO32) +ROTATE_RIGHT_FLIP = (ZERO32 + ONE32 + ZERO32) + (ONE32 + ZERO32 + ZERO32) + +TRANSFORM_MATRIX_TOP = [ + # The first two entries are just to align the indices with the EXIF orientation tags + b"", + b"", + MIRROR, + ROTATE_180, + FLIP, + ROTATE_LEFT_FLIP, + ROTATE_LEFT, + ROTATE_RIGHT_FLIP, + ROTATE_RIGHT, +] + + +def transform_init(init: bytes, orientation: int) -> bytes: + """Change the transformation matrix in the header.""" + # Find moov + moov_location = next(find_box(init, b"moov")) + mvhd_location = next(find_box(init, b"trak", moov_location)) + tkhd_location = next(find_box(init, b"tkhd", mvhd_location)) + tkhd_length = int.from_bytes( + init[tkhd_location : tkhd_location + 4], byteorder="big" + ) + return ( + init[: tkhd_location + tkhd_length - 44] + + TRANSFORM_MATRIX_TOP[orientation] + + XYW_ROW + + init[tkhd_location + tkhd_length - 8 :] + ) diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index 3bda8acfa7b614..834f396686b6f2 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -1,7 +1,7 @@ """Provide functionality to record stream.""" from __future__ import annotations -from io import BytesIO +from io import DEFAULT_BUFFER_SIZE, BytesIO import logging import os from typing import TYPE_CHECKING @@ -16,6 +16,7 @@ SEGMENT_CONTAINER_FORMAT, ) from .core import PROVIDERS, IdleTimer, Segment, StreamOutput, StreamSettings +from .fmp4utils import read_init if TYPE_CHECKING: import deque @@ -104,7 +105,11 @@ def write_segment(segment: Segment) -> None: "w", format=RECORDER_CONTAINER_FORMAT, container_options={ - "video_track_timescale": str(int(1 / source_v.time_base)) + "video_track_timescale": str(int(1 / source_v.time_base)), + "movflags": "frag_keyframe", + "min_frag_duration": str( + self.stream_settings.min_segment_duration + ), }, ) @@ -143,6 +148,18 @@ def write_segment(segment: Segment) -> None: source.close() + def write_transform_matrix_and_rename(video_path: str) -> None: + """Update the transform matrix and write to the desired filename.""" + with open(video_path + ".tmp", mode="rb") as in_file, open( + video_path, mode="wb" + ) as out_file: + init = read_init(in_file, self.stream_settings.orientation) + out_file.write(init) + in_file.seek(len(init)) + while chunk := in_file.read(DEFAULT_BUFFER_SIZE): + out_file.write(chunk) + os.remove(video_path + ".tmp") + def finish_writing( segments: deque[Segment], output: av.OutputContainer, video_path: str ) -> None: @@ -155,7 +172,7 @@ def finish_writing( return output.close() try: - os.rename(video_path + ".tmp", video_path) + write_transform_matrix_and_rename(video_path) except FileNotFoundError: _LOGGER.error( "Error writing to '%s'. There are likely multiple recordings writing to the same file", diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index aa49a6de7ae1d7..121cc435a521de 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -265,7 +265,7 @@ def create_segment(self) -> None: self._segment = Segment( sequence=self._stream_state.sequence, stream_id=self._stream_state.stream_id, - init=read_init(self._memory_file), + init=read_init(self._memory_file, self._stream_settings.orientation), # Fetch the latest StreamOutputs, which may have changed since the # worker started. stream_outputs=self._stream_state.outputs, diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index 94d77e7657e763..8f5eb141e8ee38 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -22,12 +22,14 @@ from unittest.mock import patch import av +import numpy as np import pytest from homeassistant.components.stream import KeyFrameConverter, Stream, create_stream from homeassistant.components.stream.const import ( ATTR_SETTINGS, CONF_LL_HLS, + CONF_ORIENTATION, CONF_PART_DURATION, CONF_SEGMENT_DURATION, DOMAIN, @@ -39,6 +41,11 @@ TARGET_SEGMENT_DURATION_NON_LL_HLS, ) from homeassistant.components.stream.core import StreamSettings +from homeassistant.components.stream.fmp4utils import ( + TRANSFORM_MATRIX_TOP, + XYW_ROW, + find_box, +) from homeassistant.components.stream.worker import ( StreamEndedError, StreamState, @@ -88,6 +95,7 @@ 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, ) } @@ -284,7 +292,7 @@ def run_worker(hass, stream, stream_source, stream_settings=None): {}, stream_settings or hass.data[DOMAIN][ATTR_SETTINGS], stream_state, - KeyFrameConverter(hass), + KeyFrameConverter(hass, 1), threading.Event(), ) @@ -903,18 +911,16 @@ async def test_h265_video_is_hvc1(hass, worker_finished_stream): } -async def test_get_image(hass, filename): +async def test_get_image(hass, h264_video, filename): """Test that the has_keyframe metadata matches the media.""" await async_setup_component(hass, "stream", {"stream": {}}) - source = generate_h264_video() - # Since libjpeg-turbo is not installed on the CI runner, we use a mock with patch( "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, source, {}) + stream = create_stream(hass, h264_video, {}) with patch.object(hass.config, "is_allowed_path", return_value=True): make_recording = hass.async_create_task(stream.async_record(filename)) @@ -935,6 +941,7 @@ 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" @@ -945,3 +952,84 @@ async def test_worker_disable_ll_hls(hass): stream_settings=stream_settings, ) assert stream_settings.ll_hls is False + + +async def test_rotate_init(hass, h264_video, worker_finished_stream): + """Test that the init has the proper rotation applied.""" + await async_setup_component( + hass, + "stream", + { + "stream": { + CONF_LL_HLS: True, + CONF_SEGMENT_DURATION: SEGMENT_DURATION, + # Our test video has keyframes every second. Use smaller parts so we have more + # part boundaries to better test keyframe logic. + CONF_PART_DURATION: 0.25, + } + }, + ) + + worker_finished, mock_stream = worker_finished_stream + + with patch("homeassistant.components.stream.Stream", wraps=mock_stream): + stream = create_stream( + hass, h264_video, {CONF_ORIENTATION: 2}, stream_label="camera" + ) + + recorder_output = stream.add_provider(RECORDER_PROVIDER, timeout=30) + await stream.start() + await worker_finished.wait() + + complete_segments = list(recorder_output.get_segments())[:-1] + + assert len(complete_segments) >= 1 + + # check that the init has the proper rotation matrix applied + for segment in complete_segments: + # Find moov + moov_location = next(find_box(segment.init, b"moov")) + mvhd_location = next(find_box(segment.init, b"trak", moov_location)) + tkhd_location = next(find_box(segment.init, b"tkhd", mvhd_location)) + tkhd_length = int.from_bytes( + segment.init[tkhd_location : tkhd_location + 4], byteorder="big" + ) + assert ( + segment.init[ + tkhd_location + tkhd_length - 44 : tkhd_location + tkhd_length - 8 + ] + == TRANSFORM_MATRIX_TOP[2] + XYW_ROW + ) + + await stream.stop() + + +async def test_get_image_rotated(hass, h264_video, filename): + """Test that the has_keyframe metadata matches the media.""" + await async_setup_component(hass, "stream", {"stream": {}}) + + # Since libjpeg-turbo is not installed on the CI runner, we use a mock + with patch( + "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, {CONF_ORIENTATION: orientation}) + + with patch.object(hass.config, "is_allowed_path", return_value=True): + make_recording = hass.async_create_task(stream.async_record(filename)) + await make_recording + assert stream._keyframe_converter._image is None + + assert await stream.async_get_image() == EMPTY_8_6_JPEG + await stream.stop() + assert ( + np.rot90( + mock_turbo_jpeg_singleton.instance.return_value.encode.call_args_list[ + 0 + ][0][0] + ) + == mock_turbo_jpeg_singleton.instance.return_value.encode.call_args_list[1][ + 0 + ][0] + ).all() From 1f61853465d6096a0820546d2196ebd7c5735179 Mon Sep 17 00:00:00 2001 From: Justin Wong <46082645+uvjustin@users.noreply.github.com> Date: Sun, 28 Aug 2022 15:36:31 +0000 Subject: [PATCH 02/15] Use camera prefs to set orientation --- homeassistant/components/camera/__init__.py | 6 ++++- homeassistant/components/camera/const.py | 1 + homeassistant/components/camera/prefs.py | 25 ++++++++++++++------- homeassistant/components/stream/__init__.py | 17 +++++++++----- homeassistant/components/stream/const.py | 1 - homeassistant/components/stream/core.py | 8 +++---- tests/components/camera/common.py | 8 +++++-- tests/components/camera/test_init.py | 10 ++++++++- tests/components/stream/test_hls.py | 1 + tests/components/stream/test_worker.py | 10 ++++----- 10 files changed, 59 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 5aa348c9fb8e16..7fd713c918c7e8 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -65,6 +65,8 @@ DATA_CAMERA_PREFS, DATA_RTSP_TO_WEB_RTC, DOMAIN, + PREF_ORIENTATION, + PREF_PRELOAD_STREAM, SERVICE_RECORD, STREAM_TYPE_HLS, STREAM_TYPE_WEB_RTC, @@ -874,7 +876,8 @@ async def websocket_get_prefs( { vol.Required("type"): "camera/update_prefs", vol.Required("entity_id"): cv.entity_id, - vol.Optional("preload_stream"): bool, + vol.Optional(PREF_PRELOAD_STREAM): bool, + vol.Optional(PREF_ORIENTATION): vol.All(int, vol.Range(min=1, max=8)), } ) @websocket_api.async_response @@ -959,6 +962,7 @@ async def _async_stream_endpoint_url( # Update keepalive setting which manages idle shutdown camera_prefs = hass.data[DATA_CAMERA_PREFS].get(camera.entity_id) stream.keepalive = camera_prefs.preload_stream + stream.orientation = camera_prefs.orientation stream.add_provider(fmt) await stream.start() diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py index fafed8a426683a..ab5832e48ab49e 100644 --- a/homeassistant/components/camera/const.py +++ b/homeassistant/components/camera/const.py @@ -9,6 +9,7 @@ DATA_RTSP_TO_WEB_RTC: Final = "rtsp_to_web_rtc" PREF_PRELOAD_STREAM: Final = "preload_stream" +PREF_ORIENTATION: Final = "orientation" SERVICE_RECORD: Final = "record" diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py index 08c57631a1b8bc..c5f67f9e3a7279 100644 --- a/homeassistant/components/camera/prefs.py +++ b/homeassistant/components/camera/prefs.py @@ -1,13 +1,13 @@ """Preference management for camera component.""" from __future__ import annotations -from typing import Final +from typing import Final, Union, cast from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import UNDEFINED, UndefinedType -from .const import DOMAIN, PREF_PRELOAD_STREAM +from .const import DOMAIN, PREF_ORIENTATION, PREF_PRELOAD_STREAM STORAGE_KEY: Final = DOMAIN STORAGE_VERSION: Final = 1 @@ -16,18 +16,23 @@ class CameraEntityPreferences: """Handle preferences for camera entity.""" - def __init__(self, prefs: dict[str, bool]) -> None: + def __init__(self, prefs: dict[str, bool | int]) -> None: """Initialize prefs.""" self._prefs = prefs - def as_dict(self) -> dict[str, bool]: + def as_dict(self) -> dict[str, bool | int]: """Return dictionary version.""" return self._prefs @property def preload_stream(self) -> bool: """Return if stream is loaded on hass start.""" - return self._prefs.get(PREF_PRELOAD_STREAM, False) + return cast(bool, self._prefs.get(PREF_PRELOAD_STREAM, False)) + + @property + def orientation(self) -> int: + """Return the current stream orientation settings.""" + return self._prefs.get(PREF_ORIENTATION, 1) class CameraPreferences: @@ -36,10 +41,10 @@ class CameraPreferences: def __init__(self, hass: HomeAssistant) -> None: """Initialize camera prefs.""" self._hass = hass - self._store = Store[dict[str, dict[str, bool]]]( + self._store = Store[dict[str, dict[str, Union[bool, int]]]]( hass, STORAGE_VERSION, STORAGE_KEY ) - self._prefs: dict[str, dict[str, bool]] | None = None + self._prefs: dict[str, dict[str, bool | int]] | None = None async def async_initialize(self) -> None: """Finish initializing the preferences.""" @@ -53,6 +58,7 @@ async def async_update( entity_id: str, *, preload_stream: bool | UndefinedType = UNDEFINED, + orientation: int | UndefinedType = UNDEFINED, stream_options: dict[str, str] | UndefinedType = UNDEFINED, ) -> None: """Update camera preferences.""" @@ -61,7 +67,10 @@ async def async_update( if not self._prefs.get(entity_id): self._prefs[entity_id] = {} - for key, value in ((PREF_PRELOAD_STREAM, preload_stream),): + for key, value in ( + (PREF_PRELOAD_STREAM, preload_stream), + (PREF_ORIENTATION, orientation), + ): if value is not UNDEFINED: self._prefs[entity_id][key] = value diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 5f0165c61a1dcc..7d01c6a8019ec3 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -41,7 +41,6 @@ ATTR_STREAMS, CONF_EXTRA_PART_WAIT_TIME, CONF_LL_HLS, - CONF_ORIENTATION, CONF_PART_DURATION, CONF_RTSP_TRANSPORT, CONF_SEGMENT_DURATION, @@ -125,7 +124,6 @@ def convert_stream_options( except vol.Invalid as exc: raise HomeAssistantError("Invalid stream options") from exc - stream_settings.orientation = stream_options.get(CONF_ORIENTATION, 1) if extra_wait_time := stream_options.get(CONF_EXTRA_PART_WAIT_TIME): stream_settings.hls_part_timeout += extra_wait_time if rtsp_transport := stream_options.get(CONF_RTSP_TRANSPORT): @@ -283,7 +281,7 @@ def __init__( self._thread_quit = threading.Event() self._outputs: dict[str, StreamOutput] = {} self._fast_restart_once = False - self._keyframe_converter = KeyFrameConverter(hass, stream_settings.orientation) + self._keyframe_converter = KeyFrameConverter(hass, stream_settings) self._available: bool = True self._update_callback: Callable[[], None] | None = None self._logger = ( @@ -293,6 +291,15 @@ def __init__( ) self._diagnostics = Diagnostics() + @property + def orientation(self) -> int: + """Return the current orientation setting.""" + return self._stream_settings.orientation + + @orientation.setter + def orientation(self, value: int) -> None: + 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: @@ -404,6 +411,7 @@ def _run_worker(self) -> None: 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.increment("start_worker") try: stream_worker( @@ -552,8 +560,5 @@ def _should_retry() -> bool: vol.Optional(CONF_RTSP_TRANSPORT): vol.In(RTSP_TRANSPORTS), vol.Optional(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): bool, vol.Optional(CONF_EXTRA_PART_WAIT_TIME): cv.positive_float, - # See EXIF orientations: - # https://www.cipa.jp/std/documents/e/DC-X008-Translation-2019-E.pdf - vol.Optional(CONF_ORIENTATION): vol.All(int, vol.Range(min=1, max=8)), } ) diff --git a/homeassistant/components/stream/const.py b/homeassistant/components/stream/const.py index 8d55b01b17a480..35af633435eb1c 100644 --- a/homeassistant/components/stream/const.py +++ b/homeassistant/components/stream/const.py @@ -54,4 +54,3 @@ } CONF_USE_WALLCLOCK_AS_TIMESTAMPS = "use_wallclock_as_timestamps" CONF_EXTRA_PART_WAIT_TIME = "extra_part_wait_time" -CONF_ORIENTATION = "orientation" diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 9a047dca2d3f65..dbc7839b5fdf5d 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -400,7 +400,7 @@ class KeyFrameConverter: If unsuccessful, get_image will return the previous image """ - def __init__(self, hass: HomeAssistant, orientation: int) -> None: + def __init__(self, hass: HomeAssistant, stream_settings: StreamSettings) -> None: """Initialize.""" # Keep import here so that we can import stream integration without installing reqs @@ -413,7 +413,7 @@ def __init__(self, hass: HomeAssistant, orientation: int) -> None: self._turbojpeg = TurboJPEGSingleton.instance() self._lock = asyncio.Lock() self._codec_context: CodecContext | None = None - self._orientation = orientation + self._stream_settings = stream_settings def create_codec_context(self, codec_context: CodecContext) -> None: """ @@ -488,12 +488,12 @@ def _generate_image(self, width: int | None, height: int | None) -> None: if frames: frame = frames[0] if width and height: - if self._orientation >= 5: + if self._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._orientation + frame.to_ndarray(format="bgr24"), self._stream_settings.orientation ) self._image = bytes(self._turbojpeg.encode(bgr_array)) diff --git a/tests/components/camera/common.py b/tests/components/camera/common.py index ee2a3cb2974198..157e03ad12e5c4 100644 --- a/tests/components/camera/common.py +++ b/tests/components/camera/common.py @@ -5,7 +5,11 @@ """ from unittest.mock import Mock -from homeassistant.components.camera.const import DATA_CAMERA_PREFS, PREF_PRELOAD_STREAM +from homeassistant.components.camera.const import ( + DATA_CAMERA_PREFS, + PREF_ORIENTATION, + PREF_PRELOAD_STREAM, +) EMPTY_8_6_JPEG = b"empty_8_6" WEBRTC_ANSWER = "a=sendonly" @@ -13,7 +17,7 @@ def mock_camera_prefs(hass, entity_id, prefs=None): """Fixture for cloud component.""" - prefs_to_set = {PREF_PRELOAD_STREAM: True} + prefs_to_set = {PREF_PRELOAD_STREAM: True, PREF_ORIENTATION: 1} if prefs is not None: prefs_to_set.update(prefs) hass.data[DATA_CAMERA_PREFS]._prefs[entity_id] = prefs_to_set diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index cea9c527946729..fca7420c769689 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -7,7 +7,11 @@ import pytest from homeassistant.components import camera -from homeassistant.components.camera.const import DOMAIN, PREF_PRELOAD_STREAM +from homeassistant.components.camera.const import ( + DOMAIN, + 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 @@ -300,6 +304,7 @@ async def test_websocket_update_prefs( """Test updating preference.""" await async_setup_component(hass, "camera", {}) assert setup_camera_prefs[PREF_PRELOAD_STREAM] + assert setup_camera_prefs[PREF_ORIENTATION] == 1 client = await hass_ws_client(hass) await client.send_json( { @@ -307,6 +312,7 @@ async def test_websocket_update_prefs( "type": "camera/update_prefs", "entity_id": "camera.demo_camera", "preload_stream": False, + "orientation": 3, } ) response = await client.receive_json() @@ -317,6 +323,8 @@ async def test_websocket_update_prefs( response["result"][PREF_PRELOAD_STREAM] == setup_camera_prefs[PREF_PRELOAD_STREAM] ) + assert setup_camera_prefs[PREF_ORIENTATION] == 3 + assert response["result"][PREF_ORIENTATION] == setup_camera_prefs[PREF_ORIENTATION] async def test_play_stream_service_no_source(hass, mock_camera, mock_stream): diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index 715e69fb889c4f..769309508086cb 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -180,6 +180,7 @@ async def test_hls_stream( assert stream.get_diagnostics() == { "container_format": "mov,mp4,m4a,3gp,3g2,mj2", "keepalive": False, + "orientation": 1, "start_worker": 1, "video_codec": "h264", "worker_error": 1, diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index 8f5eb141e8ee38..aa8d171a30eb74 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -29,7 +29,6 @@ from homeassistant.components.stream.const import ( ATTR_SETTINGS, CONF_LL_HLS, - CONF_ORIENTATION, CONF_PART_DURATION, CONF_SEGMENT_DURATION, DOMAIN, @@ -905,6 +904,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, "start_worker": 1, "video_codec": "hevc", "worker_error": 1, @@ -973,9 +973,8 @@ async def test_rotate_init(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, {CONF_ORIENTATION: 2}, stream_label="camera" - ) + stream = create_stream(hass, h264_video, {}, stream_label="camera") + stream._stream_settings.orientation = 2 recorder_output = stream.add_provider(RECORDER_PROVIDER, timeout=30) await stream.start() @@ -1014,7 +1013,8 @@ async def test_get_image_rotated(hass, h264_video, filename): ) 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, {CONF_ORIENTATION: orientation}) + stream = create_stream(hass, h264_video, {}) + stream._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)) From 6d06dbdbeb9ac988bb5c70ee54874263cc74d623 Mon Sep 17 00:00:00 2001 From: Justin Wong <46082645+uvjustin@users.noreply.github.com> Date: Mon, 29 Aug 2022 08:00:06 +0000 Subject: [PATCH 03/15] Add missing setter comment --- homeassistant/components/stream/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 7d01c6a8019ec3..559de0940909ca 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -298,6 +298,7 @@ def orientation(self) -> int: @orientation.setter def orientation(self, value: int) -> None: + """Set the stream orientation setting.""" self._stream_settings.orientation = value def endpoint_url(self, fmt: str) -> str: From c6fbae699dfa326a4c0b202b448f127cd54d7c79 Mon Sep 17 00:00:00 2001 From: Justin Wong <46082645+uvjustin@users.noreply.github.com> Date: Mon, 29 Aug 2022 08:51:47 +0000 Subject: [PATCH 04/15] Modify mp4 transform matrix lazily --- homeassistant/components/stream/fmp4utils.py | 6 +-- homeassistant/components/stream/hls.py | 4 +- homeassistant/components/stream/recorder.py | 6 ++- homeassistant/components/stream/worker.py | 2 +- tests/components/stream/common.py | 20 ++++++++ tests/components/stream/test_hls.py | 45 +++++++++++++++- tests/components/stream/test_worker.py | 54 -------------------- 7 files changed, 74 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/stream/fmp4utils.py b/homeassistant/components/stream/fmp4utils.py index 341bba9300833b..8e215862abac60 100644 --- a/homeassistant/components/stream/fmp4utils.py +++ b/homeassistant/components/stream/fmp4utils.py @@ -141,13 +141,11 @@ def get_codec_string(mp4_bytes: bytes) -> str: return ",".join(codecs) -def read_init(bytes_io: BufferedIOBase, orientation: int) -> bytes: +def read_init(bytes_io: BufferedIOBase) -> bytes: """Read the init from a mp4 file.""" bytes_io.seek(24) moov_len = int.from_bytes(bytes_io.read(4), byteorder="big") bytes_io.seek(0) - if orientation >= 2: - return transform_init(bytes_io.read(24 + moov_len), orientation) return bytes_io.read(24 + moov_len) @@ -180,6 +178,8 @@ def read_init(bytes_io: BufferedIOBase, orientation: int) -> bytes: def transform_init(init: bytes, orientation: int) -> bytes: """Change the transformation matrix in the header.""" + if orientation == 1: + return init # Find moov moov_location = next(find_box(init, b"moov")) mvhd_location = next(find_box(init, b"trak", moov_location)) diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py index d3bcbb360a6d54..e8920abcaa60a5 100644 --- a/homeassistant/components/stream/hls.py +++ b/homeassistant/components/stream/hls.py @@ -24,7 +24,7 @@ StreamSettings, StreamView, ) -from .fmp4utils import get_codec_string +from .fmp4utils import get_codec_string, transform_init if TYPE_CHECKING: from . import Stream @@ -339,7 +339,7 @@ async def handle( if not (segments := track.get_segments()) or not (body := segments[0].init): return web.HTTPNotFound() return web.Response( - body=body, + body=transform_init(body, stream.orientation), headers={"Content-Type": "video/mp4"}, ) diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index 834f396686b6f2..1eb7a6feedbdd0 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -16,7 +16,7 @@ SEGMENT_CONTAINER_FORMAT, ) from .core import PROVIDERS, IdleTimer, Segment, StreamOutput, StreamSettings -from .fmp4utils import read_init +from .fmp4utils import read_init, transform_init if TYPE_CHECKING: import deque @@ -153,7 +153,9 @@ def write_transform_matrix_and_rename(video_path: str) -> None: with open(video_path + ".tmp", mode="rb") as in_file, open( video_path, mode="wb" ) as out_file: - init = read_init(in_file, self.stream_settings.orientation) + init = transform_init( + read_init(in_file), self.stream_settings.orientation + ) out_file.write(init) in_file.seek(len(init)) while chunk := in_file.read(DEFAULT_BUFFER_SIZE): diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 121cc435a521de..aa49a6de7ae1d7 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -265,7 +265,7 @@ def create_segment(self) -> None: self._segment = Segment( sequence=self._stream_state.sequence, stream_id=self._stream_state.stream_id, - init=read_init(self._memory_file, self._stream_settings.orientation), + init=read_init(self._memory_file), # Fetch the latest StreamOutputs, which may have changed since the # worker started. stream_outputs=self._stream_state.outputs, diff --git a/tests/components/stream/common.py b/tests/components/stream/common.py index 7fc25cb8478788..de5b2c234ebf9b 100644 --- a/tests/components/stream/common.py +++ b/tests/components/stream/common.py @@ -9,6 +9,11 @@ import numpy as np from homeassistant.components.stream.core import Segment +from homeassistant.components.stream.fmp4utils import ( + TRANSFORM_MATRIX_TOP, + XYW_ROW, + find_box, +) FAKE_TIME = datetime.utcnow() # Segment with defaults filled in for use in tests @@ -150,3 +155,18 @@ def remux_with_audio(source, container_format, audio_codec): output.seek(0) return output + + +def assert_mp4_has_transform_matrix(mp4: bytes, orientation: int): + """Assert that the mp4 (or init) has the proper transformation matrix.""" + # Find moov + moov_location = next(find_box(mp4, b"moov")) + mvhd_location = next(find_box(mp4, b"trak", moov_location)) + tkhd_location = next(find_box(mp4, b"tkhd", mvhd_location)) + tkhd_length = int.from_bytes( + mp4[tkhd_location : tkhd_location + 4], byteorder="big" + ) + assert ( + mp4[tkhd_location + tkhd_length - 44 : tkhd_location + tkhd_length - 8] + == TRANSFORM_MATRIX_TOP[orientation] + XYW_ROW + ) diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index 769309508086cb..ad430cb6e49a05 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -21,7 +21,11 @@ import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed -from tests.components.stream.common import FAKE_TIME, DefaultSegment as Segment +from tests.components.stream.common import ( + FAKE_TIME, + DefaultSegment as Segment, + assert_mp4_has_transform_matrix, +) STREAM_SOURCE = "some-stream-source" INIT_BYTES = b"init" @@ -516,3 +520,42 @@ async def test_remove_incomplete_segment_on_exit( assert segments[-1].complete assert len(segments) == 2 await stream.stop() + + +async def test_hls_stream_rotate( + hass, setup_component, hls_stream, stream_worker_sync, h264_video +): + """ + Test hls stream with rotation applied. + + Purposefully not mocking anything here to test full + integration with the stream component. + """ + + stream_worker_sync.pause() + + # Setup demo HLS track + stream = create_stream(hass, h264_video, {}) + + # Request stream + stream.add_provider(HLS_PROVIDER) + await stream.start() + + hls_client = await hls_stream(stream) + + # Fetch master playlist + master_playlist_response = await hls_client.get() + assert master_playlist_response.status == HTTPStatus.OK + + # Fetch rotated init + stream.orientation = 6 + 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) + + # Stop stream, if it hasn't quit already + await stream.stop() diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index aa8d171a30eb74..70769840dd7cfb 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -40,11 +40,6 @@ TARGET_SEGMENT_DURATION_NON_LL_HLS, ) from homeassistant.components.stream.core import StreamSettings -from homeassistant.components.stream.fmp4utils import ( - TRANSFORM_MATRIX_TOP, - XYW_ROW, - find_box, -) from homeassistant.components.stream.worker import ( StreamEndedError, StreamState, @@ -954,55 +949,6 @@ async def test_worker_disable_ll_hls(hass): assert stream_settings.ll_hls is False -async def test_rotate_init(hass, h264_video, worker_finished_stream): - """Test that the init has the proper rotation applied.""" - await async_setup_component( - hass, - "stream", - { - "stream": { - CONF_LL_HLS: True, - CONF_SEGMENT_DURATION: SEGMENT_DURATION, - # Our test video has keyframes every second. Use smaller parts so we have more - # part boundaries to better test keyframe logic. - CONF_PART_DURATION: 0.25, - } - }, - ) - - 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._stream_settings.orientation = 2 - - recorder_output = stream.add_provider(RECORDER_PROVIDER, timeout=30) - await stream.start() - await worker_finished.wait() - - complete_segments = list(recorder_output.get_segments())[:-1] - - assert len(complete_segments) >= 1 - - # check that the init has the proper rotation matrix applied - for segment in complete_segments: - # Find moov - moov_location = next(find_box(segment.init, b"moov")) - mvhd_location = next(find_box(segment.init, b"trak", moov_location)) - tkhd_location = next(find_box(segment.init, b"tkhd", mvhd_location)) - tkhd_length = int.from_bytes( - segment.init[tkhd_location : tkhd_location + 4], byteorder="big" - ) - assert ( - segment.init[ - tkhd_location + tkhd_length - 44 : tkhd_location + tkhd_length - 8 - ] - == TRANSFORM_MATRIX_TOP[2] + XYW_ROW - ) - - await stream.stop() - - async def test_get_image_rotated(hass, h264_video, filename): """Test that the has_keyframe metadata matches the media.""" await async_setup_component(hass, "stream", {"stream": {}}) From 517d8d1337d154dc8e155212c481ee0768d11567 Mon Sep 17 00:00:00 2001 From: Justin Wong <46082645+uvjustin@users.noreply.github.com> Date: Mon, 29 Aug 2022 08:52:27 +0000 Subject: [PATCH 05/15] Add stream recorder test --- tests/components/stream/test_recorder.py | 46 ++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/tests/components/stream/test_recorder.py b/tests/components/stream/test_recorder.py index a070f60912954d..c07675c77127ac 100644 --- a/tests/components/stream/test_recorder.py +++ b/tests/components/stream/test_recorder.py @@ -20,7 +20,12 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .common import DefaultSegment as Segment, generate_h264_video, remux_with_audio +from .common import ( + DefaultSegment as Segment, + assert_mp4_has_transform_matrix, + generate_h264_video, + remux_with_audio, +) from tests.common import async_fire_time_changed @@ -72,7 +77,7 @@ async def remove_provider(self, provider): async def test_record_lookback(hass, filename, h264_video): - """Exercise record with loopback.""" + """Exercise record with lookback.""" stream = create_stream(hass, h264_video, {}) @@ -252,3 +257,40 @@ async def test_recorder_log(hass, filename, caplog): await stream.async_record(filename) assert "https://abcd:efgh@foo.bar" not in caplog.text assert "https://****:****@foo.bar" in caplog.text + + +async def test_record_stream_rotate(hass, filename, h264_video): + """Test record stream with rotation.""" + + worker_finished = asyncio.Event() + + class MockStream(Stream): + """Mock Stream so we can patch remove_provider.""" + + async def remove_provider(self, provider): + """Add a finished event to Stream.remove_provider.""" + await Stream.remove_provider(self, provider) + worker_finished.set() + + with patch("homeassistant.components.stream.Stream", wraps=MockStream): + stream = create_stream(hass, h264_video, {}) + stream.orientation = 8 + + with patch.object(hass.config, "is_allowed_path", return_value=True): + make_recording = hass.async_create_task(stream.async_record(filename)) + + # In general usage the recorder will only include what has already been + # processed by the worker. To guarantee we have some output for the test, + # wait until the worker has finished before firing + await worker_finished.wait() + + # Fire the IdleTimer + future = dt_util.utcnow() + timedelta(seconds=30) + async_fire_time_changed(hass, future) + + await make_recording + + # Assert + assert os.path.exists(filename) + with open(filename, "rb") as rotated_mp4: + assert_mp4_has_transform_matrix(rotated_mp4.read(), stream.orientation) From 425c3d89945a1046e40ae561bbcf180a0322465d Mon Sep 17 00:00:00 2001 From: Justin Wong <46082645+uvjustin@users.noreply.github.com> Date: Tue, 30 Aug 2022 07:44:53 +0000 Subject: [PATCH 06/15] Use entity registry to store orientation preferences --- homeassistant/components/camera/__init__.py | 7 ++-- homeassistant/components/camera/prefs.py | 44 +++++++++++++-------- tests/components/camera/common.py | 8 +--- tests/components/camera/test_init.py | 22 ++++++++--- 4 files changed, 50 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 7fd713c918c7e8..34746525028e76 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -891,9 +891,10 @@ async def websocket_update_prefs( changes.pop("id") changes.pop("type") entity_id = changes.pop("entity_id") - await prefs.async_update(entity_id, **changes) - - connection.send_result(msg["id"], prefs.get(entity_id).as_dict()) + if isinstance(entity_prefs := await prefs.async_update(entity_id, **changes), dict): + connection.send_result(msg["id"], entity_prefs) + else: + connection.send_error(msg["id"], "update_failed", entity_prefs) async def async_handle_snapshot_service( diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py index c5f67f9e3a7279..ea62b71d7f587e 100644 --- a/homeassistant/components/camera/prefs.py +++ b/homeassistant/components/camera/prefs.py @@ -4,6 +4,7 @@ from typing import Final, Union, cast from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import UNDEFINED, UndefinedType @@ -41,9 +42,12 @@ class CameraPreferences: def __init__(self, hass: HomeAssistant) -> None: """Initialize camera prefs.""" self._hass = hass + # The orientation prefs are stored in in the entity registry options + # The preload_stream prefs are stored in this Store self._store = Store[dict[str, dict[str, Union[bool, int]]]]( hass, STORAGE_VERSION, STORAGE_KEY ) + # Local copy of the preload_stream prefs self._prefs: dict[str, dict[str, bool | int]] | None = None async def async_initialize(self) -> None: @@ -60,24 +64,32 @@ async def async_update( preload_stream: bool | UndefinedType = UNDEFINED, orientation: int | UndefinedType = UNDEFINED, stream_options: dict[str, str] | UndefinedType = UNDEFINED, - ) -> None: - """Update camera preferences.""" - # Prefs already initialized. - assert self._prefs is not None - if not self._prefs.get(entity_id): - self._prefs[entity_id] = {} - - for key, value in ( - (PREF_PRELOAD_STREAM, preload_stream), - (PREF_ORIENTATION, orientation), - ): - if value is not UNDEFINED: - self._prefs[entity_id][key] = value - - await self._store.async_save(self._prefs) + ) -> dict[str, bool | int] | str: + """Update camera preferences. + + Returns a dict with the preferences on success or a string on error. + """ + 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 orientation is not UNDEFINED: + if (registry := er.async_get(self._hass)).async_get(entity_id): + registry.async_update_entity_options( + entity_id, DOMAIN, {PREF_ORIENTATION: orientation} + ) + else: + return "Orientation is only supported on entities set up through config flows" + return self.get(entity_id).as_dict() def get(self, entity_id: str) -> CameraEntityPreferences: """Get preferences for an entity.""" # Prefs are already initialized. assert self._prefs is not None - return CameraEntityPreferences(self._prefs.get(entity_id, {})) + 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) diff --git a/tests/components/camera/common.py b/tests/components/camera/common.py index 157e03ad12e5c4..ee2a3cb2974198 100644 --- a/tests/components/camera/common.py +++ b/tests/components/camera/common.py @@ -5,11 +5,7 @@ """ from unittest.mock import Mock -from homeassistant.components.camera.const import ( - DATA_CAMERA_PREFS, - PREF_ORIENTATION, - PREF_PRELOAD_STREAM, -) +from homeassistant.components.camera.const import DATA_CAMERA_PREFS, PREF_PRELOAD_STREAM EMPTY_8_6_JPEG = b"empty_8_6" WEBRTC_ANSWER = "a=sendonly" @@ -17,7 +13,7 @@ def mock_camera_prefs(hass, entity_id, prefs=None): """Fixture for cloud component.""" - prefs_to_set = {PREF_PRELOAD_STREAM: True, PREF_ORIENTATION: 1} + prefs_to_set = {PREF_PRELOAD_STREAM: True} if prefs is not None: prefs_to_set.update(prefs) hass.data[DATA_CAMERA_PREFS]._prefs[entity_id] = prefs_to_set diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index fca7420c769689..456cc0cf7e079d 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -21,6 +21,7 @@ STATE_UNAVAILABLE, ) from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from .common import EMPTY_8_6_JPEG, WEBRTC_ANSWER, mock_camera_prefs, mock_turbo_jpeg @@ -41,7 +42,7 @@ def mock_stream_fixture(hass): @pytest.fixture(name="setup_camera_prefs") def setup_camera_prefs_fixture(hass): """Initialize HTTP API.""" - return mock_camera_prefs(hass, "camera.demo_camera") + return mock_camera_prefs(hass, "camera.demo_uniquecamera") @pytest.fixture(name="image_mock_url") @@ -302,15 +303,23 @@ async def test_websocket_update_prefs( hass, hass_ws_client, mock_camera, setup_camera_prefs ): """Test updating preference.""" - await async_setup_component(hass, "camera", {}) assert setup_camera_prefs[PREF_PRELOAD_STREAM] - assert setup_camera_prefs[PREF_ORIENTATION] == 1 + registry = er.async_get(hass) + # Since we don't have a unique id, there is no registry entry + registry.async_get_or_create(DOMAIN, "demo", "uniquecamera") + registry.async_update_entity_options( + "camera.demo_uniquecamera", + DOMAIN, + {PREF_ORIENTATION: 1}, + ) + camera_prefs = registry.async_get("camera.demo_uniquecamera").options[DOMAIN] + assert camera_prefs[PREF_ORIENTATION] == 1 client = await hass_ws_client(hass) await client.send_json( { "id": 8, "type": "camera/update_prefs", - "entity_id": "camera.demo_camera", + "entity_id": "camera.demo_uniquecamera", "preload_stream": False, "orientation": 3, } @@ -323,8 +332,9 @@ async def test_websocket_update_prefs( response["result"][PREF_PRELOAD_STREAM] == setup_camera_prefs[PREF_PRELOAD_STREAM] ) - assert setup_camera_prefs[PREF_ORIENTATION] == 3 - assert response["result"][PREF_ORIENTATION] == setup_camera_prefs[PREF_ORIENTATION] + camera_prefs = registry.async_get("camera.demo_uniquecamera").options[DOMAIN] + assert camera_prefs[PREF_ORIENTATION] == 3 + assert response["result"][PREF_ORIENTATION] == camera_prefs[PREF_ORIENTATION] async def test_play_stream_service_no_source(hass, mock_camera, mock_stream): From f619817a9f2735463d69ddf9bba714cd0f0f31d2 Mon Sep 17 00:00:00 2001 From: Justin Wong <46082645+uvjustin@users.noreply.github.com> Date: Tue, 30 Aug 2022 12:04:22 +0000 Subject: [PATCH 07/15] Add test coverage --- tests/components/camera/common.py | 11 ---- tests/components/camera/test_init.py | 80 ++++++++++++++++++---------- 2 files changed, 53 insertions(+), 38 deletions(-) diff --git a/tests/components/camera/common.py b/tests/components/camera/common.py index ee2a3cb2974198..e30de46c07b1d6 100644 --- a/tests/components/camera/common.py +++ b/tests/components/camera/common.py @@ -5,21 +5,10 @@ """ from unittest.mock import Mock -from homeassistant.components.camera.const import DATA_CAMERA_PREFS, PREF_PRELOAD_STREAM - EMPTY_8_6_JPEG = b"empty_8_6" WEBRTC_ANSWER = "a=sendonly" -def mock_camera_prefs(hass, entity_id, prefs=None): - """Fixture for cloud component.""" - prefs_to_set = {PREF_PRELOAD_STREAM: True} - if prefs is not None: - prefs_to_set.update(prefs) - hass.data[DATA_CAMERA_PREFS]._prefs[entity_id] = prefs_to_set - return prefs_to_set - - def mock_turbo_jpeg( first_width=None, second_width=None, first_height=None, second_height=None ): diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 456cc0cf7e079d..b1263624158f07 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -8,6 +8,7 @@ from homeassistant.components import camera from homeassistant.components.camera.const import ( + DATA_CAMERA_PREFS, DOMAIN, PREF_ORIENTATION, PREF_PRELOAD_STREAM, @@ -24,7 +25,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .common import EMPTY_8_6_JPEG, WEBRTC_ANSWER, mock_camera_prefs, mock_turbo_jpeg +from .common import EMPTY_8_6_JPEG, WEBRTC_ANSWER, mock_turbo_jpeg STREAM_SOURCE = "rtsp://127.0.0.1/stream" HLS_STREAM_SOURCE = "http://127.0.0.1/example.m3u" @@ -39,12 +40,6 @@ def mock_stream_fixture(hass): ) -@pytest.fixture(name="setup_camera_prefs") -def setup_camera_prefs_fixture(hass): - """Initialize HTTP API.""" - return mock_camera_prefs(hass, "camera.demo_uniquecamera") - - @pytest.fixture(name="image_mock_url") async def image_mock_url_fixture(hass): """Fixture for get_image tests.""" @@ -299,25 +294,59 @@ async def test_websocket_get_prefs(hass, hass_ws_client, mock_camera): assert msg["success"] -async def test_websocket_update_prefs( - hass, hass_ws_client, mock_camera, setup_camera_prefs -): - """Test updating preference.""" - assert setup_camera_prefs[PREF_PRELOAD_STREAM] +async def test_websocket_update_prefs(hass, hass_ws_client, mock_camera): + """Test updating camera preferences.""" + + legacy_camera_prefs_by_entity = hass.data[DATA_CAMERA_PREFS]._prefs + + assert "demo.uniquecamera" not in legacy_camera_prefs_by_entity + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 8, + "type": "camera/update_prefs", + "entity_id": "camera.demo_uniquecamera", + "preload_stream": True, + } + ) + response = await client.receive_json() + assert response["success"] + + # preload_stream entry for this camera should have been added + assert "camera.demo_uniquecamera" in legacy_camera_prefs_by_entity + legacy_camera_prefs = legacy_camera_prefs_by_entity["camera.demo_uniquecamera"] + assert legacy_camera_prefs[PREF_PRELOAD_STREAM] is True + assert response["result"][PREF_PRELOAD_STREAM] is True + + # Try sending orientation update for entity not in entity registry + await client.send_json( + { + "id": 9, + "type": "camera/update_prefs", + "entity_id": "camera.demo_uniquecamera", + "orientation": 3, + } + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "update_failed" + registry = er.async_get(hass) - # Since we don't have a unique id, there is no registry entry + assert not registry.async_get("camera.demo_uniquecamera") + # Since we don't have a unique id, we need to create a registry entry registry.async_get_or_create(DOMAIN, "demo", "uniquecamera") registry.async_update_entity_options( "camera.demo_uniquecamera", DOMAIN, - {PREF_ORIENTATION: 1}, + {PREF_ORIENTATION: 4}, ) - camera_prefs = registry.async_get("camera.demo_uniquecamera").options[DOMAIN] - assert camera_prefs[PREF_ORIENTATION] == 1 - client = await hass_ws_client(hass) + er_camera_prefs = registry.async_get("camera.demo_uniquecamera").options[DOMAIN] + assert er_camera_prefs[PREF_ORIENTATION] == 4 + await client.send_json( { - "id": 8, + "id": 10, "type": "camera/update_prefs", "entity_id": "camera.demo_uniquecamera", "preload_stream": False, @@ -325,16 +354,13 @@ async def test_websocket_update_prefs( } ) response = await client.receive_json() - assert response["success"] - assert not setup_camera_prefs[PREF_PRELOAD_STREAM] - assert ( - response["result"][PREF_PRELOAD_STREAM] - == setup_camera_prefs[PREF_PRELOAD_STREAM] - ) - camera_prefs = registry.async_get("camera.demo_uniquecamera").options[DOMAIN] - assert camera_prefs[PREF_ORIENTATION] == 3 - assert response["result"][PREF_ORIENTATION] == camera_prefs[PREF_ORIENTATION] + + er_camera_prefs = registry.async_get("camera.demo_uniquecamera").options[DOMAIN] + assert er_camera_prefs[PREF_ORIENTATION] == 3 + assert response["result"][PREF_ORIENTATION] == er_camera_prefs[PREF_ORIENTATION] + assert legacy_camera_prefs[PREF_PRELOAD_STREAM] is False + assert response["result"][PREF_PRELOAD_STREAM] is False async def test_play_stream_service_no_source(hass, mock_camera, mock_stream): From 295575057e36d8312326309bdc332b2fdb55a101 Mon Sep 17 00:00:00 2001 From: Justin Wong <46082645+uvjustin@users.noreply.github.com> Date: Mon, 5 Sep 2022 04:57:01 +0000 Subject: [PATCH 08/15] Use exception for camera preference update failure --- homeassistant/components/camera/__init__.py | 8 +++++--- homeassistant/components/camera/prefs.py | 7 +++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 34746525028e76..afc6be481448e8 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -891,10 +891,12 @@ async def websocket_update_prefs( changes.pop("id") changes.pop("type") entity_id = changes.pop("entity_id") - if isinstance(entity_prefs := await prefs.async_update(entity_id, **changes), dict): + try: + entity_prefs = await prefs.async_update(entity_id, **changes) connection.send_result(msg["id"], entity_prefs) - else: - connection.send_error(msg["id"], "update_failed", entity_prefs) + except HomeAssistantError as ex: + _LOGGER.error("Error setting camera preferences: %s", ex) + connection.send_error(msg["id"], "update_failed", str(ex)) async def async_handle_snapshot_service( diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py index ea62b71d7f587e..effc2f619bd5f6 100644 --- a/homeassistant/components/camera/prefs.py +++ b/homeassistant/components/camera/prefs.py @@ -4,6 +4,7 @@ from typing import Final, Union, cast from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import UNDEFINED, UndefinedType @@ -64,7 +65,7 @@ async def async_update( preload_stream: bool | UndefinedType = UNDEFINED, orientation: int | UndefinedType = UNDEFINED, stream_options: dict[str, str] | UndefinedType = UNDEFINED, - ) -> dict[str, bool | int] | str: + ) -> dict[str, bool | int]: """Update camera preferences. Returns a dict with the preferences on success or a string on error. @@ -83,7 +84,9 @@ async def async_update( entity_id, DOMAIN, {PREF_ORIENTATION: orientation} ) else: - return "Orientation is only supported on entities set up through config flows" + raise HomeAssistantError( + "Orientation is only supported on entities set up through config flows" + ) return self.get(entity_id).as_dict() def get(self, entity_id: str) -> CameraEntityPreferences: From b0fd56aac823b877aa1693ff6c8e74bab0bb0134 Mon Sep 17 00:00:00 2001 From: Justin Wong <46082645+uvjustin@users.noreply.github.com> Date: Mon, 5 Sep 2022 06:26:50 +0000 Subject: [PATCH 09/15] Use lookup tuple for transform function Add comments to transform functions Modify operations in 5 and 7 for consistency --- homeassistant/components/stream/core.py | 29 ++++++++++++------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index dbc7839b5fdf5d..2a831049f998b1 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -386,6 +386,19 @@ async def handle( raise NotImplementedError() +TRANSFORM_IMAGE_FUNCTION = ( + lambda image: image, # Unused + lambda image: image, # No transform + lambda image: np.fliplr(image).copy(), # Mirror + lambda image: np.rot90(image, 2).copy(), # Rotate 180 + lambda image: np.flipud(image).copy(), # Flip + lambda image: np.flipud(np.rot90(image)).copy(), # Rotate left and flip + lambda image: np.rot90(image).copy(), # Rotate left + lambda image: np.flipud(np.rot90(image, -1)).copy(), # Rotate right and flip + lambda image: np.rot90(image, -1).copy(), # Rotate right +) + + class KeyFrameConverter: """ Enables generating and getting an image from the last keyframe seen in the stream. @@ -440,21 +453,7 @@ def transform_image(image: np.ndarray, orientation: int) -> np.ndarray: Adapted from https://github.com/lilohuang/PyTurboJPEG. """ - if orientation == 1: - return image - if orientation == 2: - return np.fliplr(image).copy() - if orientation == 3: - return np.rot90(image, 2).copy() - if orientation == 4: - return np.flipud(image).copy() - if orientation == 5: - return np.rot90(np.flipud(image), -1).copy() - if orientation == 6: - return np.rot90(image).copy() - if orientation == 7: - return np.rot90(np.flipud(image)).copy() - return np.rot90(image, -1).copy() + return TRANSFORM_IMAGE_FUNCTION[orientation](image) def _generate_image(self, width: int | None, height: int | None) -> None: """ From 51bc5207525710c240753f3c000d69965c4d02c1 Mon Sep 17 00:00:00 2001 From: Justin Wong <46082645+uvjustin@users.noreply.github.com> Date: Mon, 5 Sep 2022 06:28:56 +0000 Subject: [PATCH 10/15] Use tuple instead of list for transform matrix lookup --- homeassistant/components/stream/fmp4utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/stream/fmp4utils.py b/homeassistant/components/stream/fmp4utils.py index 8e215862abac60..ed9dd6a9724d42 100644 --- a/homeassistant/components/stream/fmp4utils.py +++ b/homeassistant/components/stream/fmp4utils.py @@ -162,7 +162,7 @@ def read_init(bytes_io: BufferedIOBase) -> bytes: ROTATE_LEFT_FLIP = (ZERO32 + NEGONE32 + ZERO32) + (NEGONE32 + ZERO32 + ZERO32) ROTATE_RIGHT_FLIP = (ZERO32 + ONE32 + ZERO32) + (ONE32 + ZERO32 + ZERO32) -TRANSFORM_MATRIX_TOP = [ +TRANSFORM_MATRIX_TOP = ( # The first two entries are just to align the indices with the EXIF orientation tags b"", b"", @@ -173,7 +173,7 @@ def read_init(bytes_io: BufferedIOBase) -> bytes: ROTATE_LEFT, ROTATE_RIGHT_FLIP, ROTATE_RIGHT, -] +) def transform_init(init: bytes, orientation: int) -> bytes: From b81df6586a15abbeb3ed3369611b1b71ed374668 Mon Sep 17 00:00:00 2001 From: Justin Wong <46082645+uvjustin@users.noreply.github.com> Date: Mon, 5 Sep 2022 06:51:07 +0000 Subject: [PATCH 11/15] Avoid using hass.data in update_prefs test --- tests/components/camera/test_init.py | 32 +++++++++++++++++----------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index b1263624158f07..314c7c8f66acf1 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -8,7 +8,6 @@ from homeassistant.components import camera from homeassistant.components.camera.const import ( - DATA_CAMERA_PREFS, DOMAIN, PREF_ORIENTATION, PREF_PRELOAD_STREAM, @@ -297,10 +296,16 @@ async def test_websocket_get_prefs(hass, hass_ws_client, mock_camera): async def test_websocket_update_prefs(hass, hass_ws_client, mock_camera): """Test updating camera preferences.""" - legacy_camera_prefs_by_entity = hass.data[DATA_CAMERA_PREFS]._prefs + client = await hass_ws_client(hass) + await client.send_json( + {"id": 7, "type": "camera/get_prefs", "entity_id": "camera.demo_uniquecamera"} + ) + msg = await client.receive_json() - assert "demo.uniquecamera" not in legacy_camera_prefs_by_entity + # There should be no preferences + assert not msg["result"] + # Update the preference client = await hass_ws_client(hass) await client.send_json( { @@ -310,19 +315,23 @@ async def test_websocket_update_prefs(hass, hass_ws_client, mock_camera): "preload_stream": True, } ) - response = await client.receive_json() - assert response["success"] + msg = await client.receive_json() + assert msg["success"] + assert msg["result"][PREF_PRELOAD_STREAM] is True + # Check that the preference was saved + await client.send_json( + {"id": 9, "type": "camera/get_prefs", "entity_id": "camera.demo_uniquecamera"} + ) + msg = await client.receive_json() # preload_stream entry for this camera should have been added - assert "camera.demo_uniquecamera" in legacy_camera_prefs_by_entity - legacy_camera_prefs = legacy_camera_prefs_by_entity["camera.demo_uniquecamera"] - assert legacy_camera_prefs[PREF_PRELOAD_STREAM] is True - assert response["result"][PREF_PRELOAD_STREAM] is True + + assert msg["result"][PREF_PRELOAD_STREAM] is True # Try sending orientation update for entity not in entity registry await client.send_json( { - "id": 9, + "id": 10, "type": "camera/update_prefs", "entity_id": "camera.demo_uniquecamera", "orientation": 3, @@ -346,7 +355,7 @@ async def test_websocket_update_prefs(hass, hass_ws_client, mock_camera): await client.send_json( { - "id": 10, + "id": 11, "type": "camera/update_prefs", "entity_id": "camera.demo_uniquecamera", "preload_stream": False, @@ -359,7 +368,6 @@ async def test_websocket_update_prefs(hass, hass_ws_client, mock_camera): er_camera_prefs = registry.async_get("camera.demo_uniquecamera").options[DOMAIN] assert er_camera_prefs[PREF_ORIENTATION] == 3 assert response["result"][PREF_ORIENTATION] == er_camera_prefs[PREF_ORIENTATION] - assert legacy_camera_prefs[PREF_PRELOAD_STREAM] is False assert response["result"][PREF_PRELOAD_STREAM] is False From b6a16a4011badd3919989b91ee76aafa2a06a23e Mon Sep 17 00:00:00 2001 From: Justin Wong <46082645+uvjustin@users.noreply.github.com> Date: Mon, 5 Sep 2022 07:02:02 +0000 Subject: [PATCH 12/15] Split update_prefs tests --- tests/components/camera/test_init.py | 29 +++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 314c7c8f66acf1..8ee5e6be391bbf 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -293,12 +293,12 @@ async def test_websocket_get_prefs(hass, hass_ws_client, mock_camera): assert msg["success"] -async def test_websocket_update_prefs(hass, hass_ws_client, mock_camera): +async def test_websocket_update_preload_prefs(hass, hass_ws_client, mock_camera): """Test updating camera preferences.""" client = await hass_ws_client(hass) await client.send_json( - {"id": 7, "type": "camera/get_prefs", "entity_id": "camera.demo_uniquecamera"} + {"id": 7, "type": "camera/get_prefs", "entity_id": "camera.demo_camera"} ) msg = await client.receive_json() @@ -306,12 +306,11 @@ async def test_websocket_update_prefs(hass, hass_ws_client, mock_camera): assert not msg["result"] # Update the preference - client = await hass_ws_client(hass) await client.send_json( { "id": 8, "type": "camera/update_prefs", - "entity_id": "camera.demo_uniquecamera", + "entity_id": "camera.demo_camera", "preload_stream": True, } ) @@ -321,13 +320,18 @@ async def test_websocket_update_prefs(hass, hass_ws_client, mock_camera): # Check that the preference was saved await client.send_json( - {"id": 9, "type": "camera/get_prefs", "entity_id": "camera.demo_uniquecamera"} + {"id": 9, "type": "camera/get_prefs", "entity_id": "camera.demo_camera"} ) msg = await client.receive_json() # preload_stream entry for this camera should have been added - assert msg["result"][PREF_PRELOAD_STREAM] is True + +async def test_websocket_update_orientation_prefs(hass, hass_ws_client, mock_camera): + """Test updating camera preferences.""" + + client = await hass_ws_client(hass) + # Try sending orientation update for entity not in entity registry await client.send_json( { @@ -348,17 +352,14 @@ async def test_websocket_update_prefs(hass, hass_ws_client, mock_camera): registry.async_update_entity_options( "camera.demo_uniquecamera", DOMAIN, - {PREF_ORIENTATION: 4}, + {}, ) - er_camera_prefs = registry.async_get("camera.demo_uniquecamera").options[DOMAIN] - assert er_camera_prefs[PREF_ORIENTATION] == 4 await client.send_json( { "id": 11, "type": "camera/update_prefs", "entity_id": "camera.demo_uniquecamera", - "preload_stream": False, "orientation": 3, } ) @@ -368,7 +369,13 @@ async def test_websocket_update_prefs(hass, hass_ws_client, mock_camera): er_camera_prefs = registry.async_get("camera.demo_uniquecamera").options[DOMAIN] assert er_camera_prefs[PREF_ORIENTATION] == 3 assert response["result"][PREF_ORIENTATION] == er_camera_prefs[PREF_ORIENTATION] - assert response["result"][PREF_PRELOAD_STREAM] is False + # Check that the preference was saved + await client.send_json( + {"id": 12, "type": "camera/get_prefs", "entity_id": "camera.demo_uniquecamera"} + ) + msg = await client.receive_json() + # preload_stream entry for this camera should have been added + assert msg["result"]["orientation"] == 3 async def test_play_stream_service_no_source(hass, mock_camera, mock_stream): From 0c5dd14c3fd550cde5fe5a347a278b0fe1d92ef5 Mon Sep 17 00:00:00 2001 From: Justin Wong <46082645+uvjustin@users.noreply.github.com> Date: Mon, 5 Sep 2022 07:05:13 +0000 Subject: [PATCH 13/15] Revert recorder container_options change --- homeassistant/components/stream/recorder.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index 1eb7a6feedbdd0..e3926ebcd836ea 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -105,11 +105,7 @@ def write_segment(segment: Segment) -> None: "w", format=RECORDER_CONTAINER_FORMAT, container_options={ - "video_track_timescale": str(int(1 / source_v.time_base)), - "movflags": "frag_keyframe", - "min_frag_duration": str( - self.stream_settings.min_segment_duration - ), + "video_track_timescale": str(int(1 / source_v.time_base)) }, ) From b8bb1abf96ee72e3196f449e2175539ddbc64e46 Mon Sep 17 00:00:00 2001 From: Justin Wong <46082645+uvjustin@users.noreply.github.com> Date: Mon, 5 Sep 2022 07:06:52 +0000 Subject: [PATCH 14/15] Remove attribution --- homeassistant/components/stream/core.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 2a831049f998b1..0fa57913269d36 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -449,10 +449,7 @@ def create_codec_context(self, codec_context: CodecContext) -> None: @staticmethod def transform_image(image: np.ndarray, orientation: int) -> np.ndarray: - """Transform image to a given orientation. - - Adapted from https://github.com/lilohuang/PyTurboJPEG. - """ + """Transform image to a given orientation.""" return TRANSFORM_IMAGE_FUNCTION[orientation](image) def _generate_image(self, width: int | None, height: int | None) -> None: From 45e3e4364631990af5935a44e3423c34d0b095ec Mon Sep 17 00:00:00 2001 From: Justin Wong <46082645+uvjustin@users.noreply.github.com> Date: Mon, 5 Sep 2022 07:11:45 +0000 Subject: [PATCH 15/15] Fix comment in test --- tests/components/camera/test_init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 8ee5e6be391bbf..71415284d35ac7 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -374,7 +374,7 @@ async def test_websocket_update_orientation_prefs(hass, hass_ws_client, mock_cam {"id": 12, "type": "camera/get_prefs", "entity_id": "camera.demo_uniquecamera"} ) msg = await client.receive_json() - # preload_stream entry for this camera should have been added + # orientation entry for this camera should have been added assert msg["result"]["orientation"] == 3