Add orientation transforms to stream (#77439)

This commit is contained in:
uvjustin 2022-09-06 13:31:36 +08:00 committed by GitHub
parent bb77af71ff
commit 852b0caf5b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 391 additions and 71 deletions

View File

@ -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()

View File

@ -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"

View File

@ -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)

View File

@ -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(

View File

@ -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(

View File

@ -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 :]
)

View File

@ -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"},
)

View File

@ -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",

View File

@ -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
):

View File

@ -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."""

View File

@ -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
)

View File

@ -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()

View File

@ -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)

View File

@ -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()