mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 00:37:53 +00:00
Add orientation transforms to stream (#77439)
This commit is contained in:
parent
bb77af71ff
commit
852b0caf5b
@ -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()
|
||||
|
@ -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"
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -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 :]
|
||||
)
|
||||
|
@ -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"},
|
||||
)
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
):
|
||||
|
@ -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."""
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
Loading…
x
Reference in New Issue
Block a user