diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 5aa348c9fb8..afc6be48144 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -65,6 +65,8 @@ from .const import ( # noqa: F401 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 @@ -888,9 +891,12 @@ 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()) + try: + entity_prefs = await prefs.async_update(entity_id, **changes) + connection.send_result(msg["id"], 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( @@ -959,6 +965,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 fafed8a4266..ab5832e48ab 100644 --- a/homeassistant/components/camera/const.py +++ b/homeassistant/components/camera/const.py @@ -9,6 +9,7 @@ DATA_CAMERA_PREFS: Final = "camera_prefs" 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 08c57631a1b..effc2f619bd 100644 --- a/homeassistant/components/camera/prefs.py +++ b/homeassistant/components/camera/prefs.py @@ -1,13 +1,15 @@ """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.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er 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 +18,23 @@ STORAGE_VERSION: Final = 1 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 +43,13 @@ class CameraPreferences: def __init__(self, hass: HomeAssistant) -> None: """Initialize camera prefs.""" self._hass = hass - self._store = Store[dict[str, dict[str, bool]]]( + # 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 ) - self._prefs: dict[str, dict[str, bool]] | None = None + # Local copy of the preload_stream prefs + self._prefs: dict[str, dict[str, bool | int]] | None = None async def async_initialize(self) -> None: """Finish initializing the preferences.""" @@ -53,22 +63,36 @@ class CameraPreferences: entity_id: str, *, 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] = {} + ) -> dict[str, bool | int]: + """Update camera preferences. - for key, value in ((PREF_PRELOAD_STREAM, preload_stream),): - if value is not UNDEFINED: - self._prefs[entity_id][key] = value + 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) - 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: + 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: """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/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 354f9a77672..559de094090 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -229,6 +229,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 +281,7 @@ class Stream: 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) self._available: bool = True self._update_callback: Callable[[], None] | None = None self._logger = ( @@ -290,6 +291,16 @@ class Stream: ) 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: + """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: @@ -401,6 +412,7 @@ class Stream: 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( diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 8c456af91aa..0fa57913269 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any 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 @@ 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=1, ) @@ -383,6 +386,19 @@ class StreamView(HomeAssistantView): 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. @@ -397,7 +413,7 @@ class KeyFrameConverter: If unsuccessful, get_image will return the previous image """ - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, stream_settings: StreamSettings) -> None: """Initialize.""" # Keep import here so that we can import stream integration without installing reqs @@ -410,6 +426,7 @@ class KeyFrameConverter: self._turbojpeg = TurboJPEGSingleton.instance() self._lock = asyncio.Lock() self._codec_context: CodecContext | None = None + self._stream_settings = stream_settings def create_codec_context(self, codec_context: CodecContext) -> None: """ @@ -430,6 +447,11 @@ class KeyFrameConverter: 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.""" + return TRANSFORM_IMAGE_FUNCTION[orientation](image) + def _generate_image(self, width: int | None, height: int | None) -> None: """ Generate the keyframe image. @@ -462,8 +484,13 @@ class KeyFrameConverter: 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._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 + ) 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 313f5632841..ed9dd6a9724 100644 --- a/homeassistant/components/stream/fmp4utils.py +++ b/homeassistant/components/stream/fmp4utils.py @@ -5,7 +5,7 @@ from collections.abc import Generator 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) -> 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) 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.""" + 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)) + 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/hls.py b/homeassistant/components/stream/hls.py index d3bcbb360a6..e8920abcaa6 100644 --- a/homeassistant/components/stream/hls.py +++ b/homeassistant/components/stream/hls.py @@ -24,7 +24,7 @@ from .core import ( 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 @@ class HlsInitView(StreamView): 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 3910a2d2fed..1eb7a6feedb 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 @@ from .const import ( SEGMENT_CONTAINER_FORMAT, ) from .core import PROVIDERS, IdleTimer, Segment, StreamOutput, StreamSettings +from .fmp4utils import read_init, transform_init if TYPE_CHECKING: import deque @@ -147,6 +148,20 @@ class RecorderOutput(StreamOutput): 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 = 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): + out_file.write(chunk) + os.remove(video_path + ".tmp") + def finish_writing( segments: deque[Segment], output: av.OutputContainer, video_path: str ) -> None: @@ -159,7 +174,7 @@ class RecorderOutput(StreamOutput): 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/tests/components/camera/common.py b/tests/components/camera/common.py index ee2a3cb2974..e30de46c07b 100644 --- a/tests/components/camera/common.py +++ b/tests/components/camera/common.py @@ -5,21 +5,10 @@ components. Instead call the service directly. """ 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 cea9c527946..71415284d35 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -7,7 +7,11 @@ from unittest.mock import AsyncMock, Mock, PropertyMock, mock_open, patch 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 @@ -17,9 +21,10 @@ from homeassistant.const import ( 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 +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" @@ -34,12 +39,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_camera") - - @pytest.fixture(name="image_mock_url") async def image_mock_url_fixture(hass): """Fixture for get_image tests.""" @@ -294,30 +293,90 @@ 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.""" - await async_setup_component(hass, "camera", {}) - assert setup_camera_prefs[PREF_PRELOAD_STREAM] +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_camera"} + ) + msg = await client.receive_json() + + # There should be no preferences + assert not msg["result"] + + # Update the preference await client.send_json( { "id": 8, "type": "camera/update_prefs", "entity_id": "camera.demo_camera", - "preload_stream": False, + "preload_stream": True, + } + ) + 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_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( + { + "id": 10, + "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" - assert response["success"] - assert not setup_camera_prefs[PREF_PRELOAD_STREAM] - assert ( - response["result"][PREF_PRELOAD_STREAM] - == setup_camera_prefs[PREF_PRELOAD_STREAM] + registry = er.async_get(hass) + 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, + {}, ) + await client.send_json( + { + "id": 11, + "type": "camera/update_prefs", + "entity_id": "camera.demo_uniquecamera", + "orientation": 3, + } + ) + response = await client.receive_json() + assert response["success"] + + 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] + # 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() + # orientation 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): """Test camera play_stream service.""" diff --git a/tests/components/stream/common.py b/tests/components/stream/common.py index 7fc25cb8478..de5b2c234eb 100644 --- a/tests/components/stream/common.py +++ b/tests/components/stream/common.py @@ -9,6 +9,11 @@ import av 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 715e69fb889..ad430cb6e49 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -21,7 +21,11 @@ from homeassistant.setup import async_setup_component 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" @@ -180,6 +184,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, @@ -515,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_recorder.py b/tests/components/stream/test_recorder.py index a070f609129..c07675c7712 100644 --- a/tests/components/stream/test_recorder.py +++ b/tests/components/stream/test_recorder.py @@ -20,7 +20,12 @@ from homeassistant.exceptions import HomeAssistantError 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 test_record_stream(hass, filename, h264_video): 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) diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index 94d77e7657e..70769840dd7 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -22,6 +22,7 @@ import threading from unittest.mock import patch import av +import numpy as np import pytest from homeassistant.components.stream import KeyFrameConverter, Stream, create_stream @@ -88,6 +89,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 +286,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(), ) @@ -897,24 +899,23 @@ 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, } -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 +936,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 +947,35 @@ async def test_worker_disable_ll_hls(hass): stream_settings=stream_settings, ) assert stream_settings.ll_hls is False + + +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, {}) + 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)) + 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()