Converge stream av open methods, options, and error handling (#134020)

* Converge stream av open methods, options, and error handling

* Remove exception that is never thrown

* Update exceptions thrown in generic tests

* Increase stream test coverage
This commit is contained in:
Allen Porter 2024-12-27 18:47:33 -08:00 committed by GitHub
parent 07ae9b15d0
commit 6edf06f8a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 267 additions and 206 deletions

View File

@ -255,10 +255,6 @@ async def async_test_and_preview_stream(
""" """
if not (stream_source := info.get(CONF_STREAM_SOURCE)): if not (stream_source := info.get(CONF_STREAM_SOURCE)):
return None return None
# Import from stream.worker as stream cannot reexport from worker
# without forcing the av dependency on default_config
# pylint: disable-next=import-outside-toplevel
from homeassistant.components.stream.worker import StreamWorkerError
if not isinstance(stream_source, template_helper.Template): if not isinstance(stream_source, template_helper.Template):
stream_source = template_helper.Template(stream_source, hass) stream_source = template_helper.Template(stream_source, hass)
@ -294,8 +290,6 @@ async def async_test_and_preview_stream(
f"{DOMAIN}.test_stream", f"{DOMAIN}.test_stream",
) )
hls_provider = stream.add_provider(HLS_PROVIDER) hls_provider = stream.add_provider(HLS_PROVIDER)
except StreamWorkerError as err:
raise InvalidStreamException("unknown_with_details", str(err)) from err
except PermissionError as err: except PermissionError as err:
raise InvalidStreamException("stream_not_permitted") from err raise InvalidStreamException("stream_not_permitted") from err
except OSError as err: except OSError as err:

View File

@ -20,7 +20,6 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
import copy import copy
from enum import IntEnum
import logging import logging
import secrets import secrets
import threading import threading
@ -41,12 +40,12 @@ from homeassistant.util.async_ import create_eager_task
from .const import ( from .const import (
ATTR_ENDPOINTS, ATTR_ENDPOINTS,
ATTR_PREFER_TCP,
ATTR_SETTINGS, ATTR_SETTINGS,
ATTR_STREAMS, ATTR_STREAMS,
CONF_EXTRA_PART_WAIT_TIME, CONF_EXTRA_PART_WAIT_TIME,
CONF_LL_HLS, CONF_LL_HLS,
CONF_PART_DURATION, CONF_PART_DURATION,
CONF_PREFER_TCP,
CONF_RTSP_TRANSPORT, CONF_RTSP_TRANSPORT,
CONF_SEGMENT_DURATION, CONF_SEGMENT_DURATION,
CONF_USE_WALLCLOCK_AS_TIMESTAMPS, CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
@ -62,6 +61,7 @@ from .const import (
SOURCE_TIMEOUT, SOURCE_TIMEOUT,
STREAM_RESTART_INCREMENT, STREAM_RESTART_INCREMENT,
STREAM_RESTART_RESET_TIME, STREAM_RESTART_RESET_TIME,
StreamClientError,
) )
from .core import ( from .core import (
PROVIDERS, PROVIDERS,
@ -73,11 +73,10 @@ from .core import (
StreamSettings, StreamSettings,
) )
from .diagnostics import Diagnostics from .diagnostics import Diagnostics
from .exceptions import StreamOpenClientError, StreamWorkerError
from .hls import HlsStreamOutput, async_setup_hls from .hls import HlsStreamOutput, async_setup_hls
if TYPE_CHECKING: if TYPE_CHECKING:
from av.container import InputContainer, OutputContainer
from homeassistant.components.camera import DynamicStreamSettings from homeassistant.components.camera import DynamicStreamSettings
__all__ = [ __all__ = [
@ -92,6 +91,8 @@ __all__ = [
"RTSP_TRANSPORTS", "RTSP_TRANSPORTS",
"SOURCE_TIMEOUT", "SOURCE_TIMEOUT",
"Stream", "Stream",
"StreamClientError",
"StreamOpenClientError",
"create_stream", "create_stream",
"Orientation", "Orientation",
] ]
@ -99,91 +100,6 @@ __all__ = [
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class StreamClientError(IntEnum):
"""Enum for stream client errors."""
BadRequest = 400
Unauthorized = 401
Forbidden = 403
NotFound = 404
Other = 4
class StreamOpenClientError(HomeAssistantError):
"""Raised when client error received when trying to open a stream.
:param stream_client_error: The type of client error
"""
def __init__(
self, *args: Any, stream_client_error: StreamClientError, **kwargs: Any
) -> None:
self.stream_client_error = stream_client_error
super().__init__(*args, **kwargs)
async def _async_try_open_stream(
hass: HomeAssistant, source: str, pyav_options: dict[str, str] | None = None
) -> InputContainer | OutputContainer:
"""Try to open a stream.
Will raise StreamOpenClientError if an http client error is encountered.
"""
return await hass.loop.run_in_executor(None, _try_open_stream, source, pyav_options)
def _try_open_stream(
source: str, pyav_options: dict[str, str] | None = None
) -> InputContainer | OutputContainer:
"""Try to open a stream.
Will raise StreamOpenClientError if an http client error is encountered.
"""
import av # pylint: disable=import-outside-toplevel
if pyav_options is None:
pyav_options = {}
default_pyav_options = {
"rtsp_flags": CONF_PREFER_TCP,
"timeout": str(SOURCE_TIMEOUT),
}
pyav_options = {
**default_pyav_options,
**pyav_options,
}
try:
container = av.open(source, options=pyav_options, timeout=5)
except av.HTTPBadRequestError as ex:
raise StreamOpenClientError(
stream_client_error=StreamClientError.BadRequest
) from ex
except av.HTTPUnauthorizedError as ex:
raise StreamOpenClientError(
stream_client_error=StreamClientError.Unauthorized
) from ex
except av.HTTPForbiddenError as ex:
raise StreamOpenClientError(
stream_client_error=StreamClientError.Forbidden
) from ex
except av.HTTPNotFoundError as ex:
raise StreamOpenClientError(
stream_client_error=StreamClientError.NotFound
) from ex
except av.HTTPOtherClientError as ex:
raise StreamOpenClientError(stream_client_error=StreamClientError.Other) from ex
else:
return container
async def async_check_stream_client_error( async def async_check_stream_client_error(
hass: HomeAssistant, source: str, pyav_options: dict[str, str] | None = None hass: HomeAssistant, source: str, pyav_options: dict[str, str] | None = None
) -> None: ) -> None:
@ -192,18 +108,24 @@ async def async_check_stream_client_error(
Raise StreamOpenClientError if an http client error is encountered. Raise StreamOpenClientError if an http client error is encountered.
""" """
await hass.loop.run_in_executor( await hass.loop.run_in_executor(
None, _check_stream_client_error, source, pyav_options None, _check_stream_client_error, hass, source, pyav_options
) )
def _check_stream_client_error( def _check_stream_client_error(
source: str, pyav_options: dict[str, str] | None = None hass: HomeAssistant, source: str, options: dict[str, str] | None = None
) -> None: ) -> None:
"""Check if a stream can be successfully opened. """Check if a stream can be successfully opened.
Raise StreamOpenClientError if an http client error is encountered. Raise StreamOpenClientError if an http client error is encountered.
""" """
_try_open_stream(source, pyav_options).close() from .worker import try_open_stream # pylint: disable=import-outside-toplevel
pyav_options, _ = _convert_stream_options(hass, source, options or {})
try:
try_open_stream(source, pyav_options).close()
except StreamWorkerError as err:
raise StreamOpenClientError(str(err), err.error_code) from err
def redact_credentials(url: str) -> str: def redact_credentials(url: str) -> str:
@ -219,6 +141,42 @@ def redact_credentials(url: str) -> str:
return str(yurl.update_query(redacted_query_params)) return str(yurl.update_query(redacted_query_params))
def _convert_stream_options(
hass: HomeAssistant,
stream_source: str,
stream_options: Mapping[str, str | bool | float],
) -> tuple[dict[str, str], StreamSettings]:
"""Convert options from stream options into PyAV options and stream settings."""
if DOMAIN not in hass.data:
raise HomeAssistantError("Stream integration is not set up.")
stream_settings = copy.copy(hass.data[DOMAIN][ATTR_SETTINGS])
pyav_options: dict[str, str] = {}
try:
STREAM_OPTIONS_SCHEMA(stream_options)
except vol.Invalid as exc:
raise HomeAssistantError(f"Invalid stream options: {exc}") from exc
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):
assert isinstance(rtsp_transport, str)
# The PyAV options currently match the stream CONF constants, but this
# will not necessarily always be the case, so they are hard coded here
pyav_options["rtsp_transport"] = rtsp_transport
if stream_options.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
pyav_options["use_wallclock_as_timestamps"] = "1"
# For RTSP streams, prefer TCP
if isinstance(stream_source, str) and stream_source[:7] == "rtsp://":
pyav_options = {
"rtsp_flags": ATTR_PREFER_TCP,
"stimeout": "5000000",
**pyav_options,
}
return pyav_options, stream_settings
def create_stream( def create_stream(
hass: HomeAssistant, hass: HomeAssistant,
stream_source: str, stream_source: str,
@ -234,41 +192,13 @@ def create_stream(
The stream_label is a string used as an additional message in logging. The stream_label is a string used as an additional message in logging.
""" """
def convert_stream_options(
hass: HomeAssistant, stream_options: Mapping[str, str | bool | float]
) -> tuple[dict[str, str], StreamSettings]:
"""Convert options from stream options into PyAV options and stream settings."""
stream_settings = copy.copy(hass.data[DOMAIN][ATTR_SETTINGS])
pyav_options: dict[str, str] = {}
try:
STREAM_OPTIONS_SCHEMA(stream_options)
except vol.Invalid as exc:
raise HomeAssistantError("Invalid stream options") from exc
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):
assert isinstance(rtsp_transport, str)
# The PyAV options currently match the stream CONF constants, but this
# will not necessarily always be the case, so they are hard coded here
pyav_options["rtsp_transport"] = rtsp_transport
if stream_options.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
pyav_options["use_wallclock_as_timestamps"] = "1"
return pyav_options, stream_settings
if DOMAIN not in hass.config.components: if DOMAIN not in hass.config.components:
raise HomeAssistantError("Stream integration is not set up.") raise HomeAssistantError("Stream integration is not set up.")
# Convert extra stream options into PyAV options and stream settings # Convert extra stream options into PyAV options and stream settings
pyav_options, stream_settings = convert_stream_options(hass, options) pyav_options, stream_settings = _convert_stream_options(
# For RTSP streams, prefer TCP hass, stream_source, options
if isinstance(stream_source, str) and stream_source[:7] == "rtsp://": )
pyav_options = {
"rtsp_flags": "prefer_tcp",
"stimeout": "5000000",
**pyav_options,
}
stream = Stream( stream = Stream(
hass, hass,
@ -531,7 +461,7 @@ class Stream:
"""Handle consuming streams and restart keepalive streams.""" """Handle consuming streams and restart keepalive streams."""
# Keep import here so that we can import stream integration without installing reqs # Keep import here so that we can import stream integration without installing reqs
# pylint: disable-next=import-outside-toplevel # pylint: disable-next=import-outside-toplevel
from .worker import StreamState, StreamWorkerError, stream_worker from .worker import StreamState, stream_worker
stream_state = StreamState(self.hass, self.outputs, self._diagnostics) stream_state = StreamState(self.hass, self.outputs, self._diagnostics)
wait_timeout = 0 wait_timeout = 0

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from enum import IntEnum
from typing import Final from typing import Final
DOMAIN = "stream" DOMAIN = "stream"
@ -48,7 +49,7 @@ CONF_LL_HLS = "ll_hls"
CONF_PART_DURATION = "part_duration" CONF_PART_DURATION = "part_duration"
CONF_SEGMENT_DURATION = "segment_duration" CONF_SEGMENT_DURATION = "segment_duration"
CONF_PREFER_TCP = "prefer_tcp" ATTR_PREFER_TCP = "prefer_tcp"
CONF_RTSP_TRANSPORT = "rtsp_transport" CONF_RTSP_TRANSPORT = "rtsp_transport"
# The first dict entry below may be used as the default when populating options # The first dict entry below may be used as the default when populating options
RTSP_TRANSPORTS = { RTSP_TRANSPORTS = {
@ -59,3 +60,18 @@ RTSP_TRANSPORTS = {
} }
CONF_USE_WALLCLOCK_AS_TIMESTAMPS = "use_wallclock_as_timestamps" CONF_USE_WALLCLOCK_AS_TIMESTAMPS = "use_wallclock_as_timestamps"
CONF_EXTRA_PART_WAIT_TIME = "extra_part_wait_time" CONF_EXTRA_PART_WAIT_TIME = "extra_part_wait_time"
class StreamClientError(IntEnum):
"""Enum for stream client errors.
These are errors that can be returned by the stream client when trying to
open a stream. The caller should not interpret the int values directly, but
should use the enum values instead.
"""
BadRequest = 400
Unauthorized = 401
Forbidden = 403
NotFound = 404
Other = 4

View File

@ -0,0 +1,32 @@
"""Stream component exceptions."""
from homeassistant.exceptions import HomeAssistantError
from .const import StreamClientError
class StreamOpenClientError(HomeAssistantError):
"""Raised when client error received when trying to open a stream.
:param stream_client_error: The type of client error
"""
def __init__(self, message: str, error_code: StreamClientError) -> None:
"""Initialize a stream open client error."""
super().__init__(message)
self.error_code = error_code
class StreamWorkerError(Exception):
"""An exception thrown while processing a stream."""
def __init__(
self, message: str, error_code: StreamClientError = StreamClientError.Other
) -> None:
"""Initialize a stream worker error."""
super().__init__(message)
self.error_code = error_code
class StreamEndedError(StreamWorkerError):
"""Raised when the stream is complete, exposed for facilitating testing."""

View File

@ -15,6 +15,7 @@ from typing import Any, Self, cast
import av import av
import av.audio import av.audio
import av.container import av.container
from av.container import InputContainer
import av.stream import av.stream
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -29,6 +30,7 @@ from .const import (
PACKETS_TO_WAIT_FOR_AUDIO, PACKETS_TO_WAIT_FOR_AUDIO,
SEGMENT_CONTAINER_FORMAT, SEGMENT_CONTAINER_FORMAT,
SOURCE_TIMEOUT, SOURCE_TIMEOUT,
StreamClientError,
) )
from .core import ( from .core import (
STREAM_SETTINGS_NON_LL_HLS, STREAM_SETTINGS_NON_LL_HLS,
@ -39,6 +41,7 @@ from .core import (
StreamSettings, StreamSettings,
) )
from .diagnostics import Diagnostics from .diagnostics import Diagnostics
from .exceptions import StreamEndedError, StreamWorkerError
from .fmp4utils import read_init from .fmp4utils import read_init
from .hls import HlsStreamOutput from .hls import HlsStreamOutput
@ -46,10 +49,6 @@ _LOGGER = logging.getLogger(__name__)
NEGATIVE_INF = float("-inf") NEGATIVE_INF = float("-inf")
class StreamWorkerError(Exception):
"""An exception thrown while processing a stream."""
def redact_av_error_string(err: av.FFmpegError) -> str: def redact_av_error_string(err: av.FFmpegError) -> str:
"""Return an error string with credentials redacted from the url.""" """Return an error string with credentials redacted from the url."""
parts = [str(err.type), err.strerror] # type: ignore[attr-defined] parts = [str(err.type), err.strerror] # type: ignore[attr-defined]
@ -58,10 +57,6 @@ def redact_av_error_string(err: av.FFmpegError) -> str:
return ", ".join(parts) return ", ".join(parts)
class StreamEndedError(StreamWorkerError):
"""Raised when the stream is complete, exposed for facilitating testing."""
class StreamState: class StreamState:
"""Responsible for trakcing output and playback state for a stream. """Responsible for trakcing output and playback state for a stream.
@ -512,6 +507,47 @@ def get_audio_bitstream_filter(
return None return None
def try_open_stream(
source: str,
pyav_options: dict[str, str],
) -> InputContainer:
"""Try to open a stream.
Will raise StreamOpenClientError if an http client error is encountered.
"""
try:
return av.open(source, options=pyav_options, timeout=SOURCE_TIMEOUT)
except av.HTTPBadRequestError as err:
raise StreamWorkerError(
f"Bad Request Error opening stream ({redact_av_error_string(err)})",
error_code=StreamClientError.BadRequest,
) from err
except av.HTTPUnauthorizedError as err:
raise StreamWorkerError(
f"Unauthorized error opening stream ({redact_av_error_string(err)})",
error_code=StreamClientError.Unauthorized,
) from err
except av.HTTPForbiddenError as err:
raise StreamWorkerError(
f"Forbidden error opening stream ({redact_av_error_string(err)})",
error_code=StreamClientError.Forbidden,
) from err
except av.HTTPNotFoundError as err:
raise StreamWorkerError(
f"Not Found error opening stream ({redact_av_error_string(err)})",
error_code=StreamClientError.NotFound,
) from err
except av.FFmpegError as err:
raise StreamWorkerError(
f"Error opening stream ({redact_av_error_string(err)})"
) from err
def stream_worker( def stream_worker(
source: str, source: str,
pyav_options: dict[str, str], pyav_options: dict[str, str],
@ -526,12 +562,7 @@ def stream_worker(
# the stimeout option was renamed to timeout as of ffmpeg 5.0 # the stimeout option was renamed to timeout as of ffmpeg 5.0
pyav_options["timeout"] = pyav_options["stimeout"] pyav_options["timeout"] = pyav_options["stimeout"]
del pyav_options["stimeout"] del pyav_options["stimeout"]
try: container = try_open_stream(source, pyav_options)
container = av.open(source, options=pyav_options, timeout=SOURCE_TIMEOUT)
except av.FFmpegError as err:
raise StreamWorkerError(
f"Error opening stream ({redact_av_error_string(err)})"
) from err
try: try:
video_stream = container.streams.video[0] video_stream = container.streams.video[0]
except (KeyError, IndexError) as ex: except (KeyError, IndexError) as ex:

View File

@ -130,7 +130,7 @@ class TPLinkCameraEntity(CoordinatedTPLinkEntity, Camera):
try: try:
await stream.async_check_stream_client_error(self.hass, video_url) await stream.async_check_stream_client_error(self.hass, video_url)
except stream.StreamOpenClientError as ex: except stream.StreamOpenClientError as ex:
if ex.stream_client_error is stream.StreamClientError.Unauthorized: if ex.error_code is stream.StreamClientError.Unauthorized:
_LOGGER.debug( _LOGGER.debug(
"Camera stream failed authentication for %s", "Camera stream failed authentication for %s",
self._device.host, self._device.host,

View File

@ -468,7 +468,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
try: try:
await stream.async_check_stream_client_error(self.hass, rtsp_url) await stream.async_check_stream_client_error(self.hass, rtsp_url)
except stream.StreamOpenClientError as ex: except stream.StreamOpenClientError as ex:
if ex.stream_client_error is stream.StreamClientError.Unauthorized: if ex.error_code is stream.StreamClientError.Unauthorized:
errors["base"] = "invalid_camera_auth" errors["base"] = "invalid_camera_auth"
else: else:
_LOGGER.debug( _LOGGER.debug(

View File

@ -30,7 +30,6 @@ from homeassistant.components.stream import (
CONF_RTSP_TRANSPORT, CONF_RTSP_TRANSPORT,
CONF_USE_WALLCLOCK_AS_TIMESTAMPS, CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
) )
from homeassistant.components.stream.worker import StreamWorkerError
from homeassistant.config_entries import ConfigEntryState, ConfigFlowResult from homeassistant.config_entries import ConfigEntryState, ConfigFlowResult
from homeassistant.const import ( from homeassistant.const import (
CONF_AUTHENTICATION, CONF_AUTHENTICATION,
@ -646,25 +645,6 @@ async def test_form_stream_other_error(hass: HomeAssistant, user_flow) -> None:
await hass.async_block_till_done() await hass.async_block_till_done()
@respx.mock
@pytest.mark.usefixtures("fakeimg_png")
async def test_form_stream_worker_error(
hass: HomeAssistant, user_flow: ConfigFlowResult
) -> None:
"""Test we handle a StreamWorkerError and pass the message through."""
with patch(
"homeassistant.components.generic.config_flow.create_stream",
side_effect=StreamWorkerError("Some message"),
):
result2 = await hass.config_entries.flow.async_configure(
user_flow["flow_id"],
TESTDATA,
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"stream_source": "unknown_with_details"}
assert result2["description_placeholders"] == {"error": "Some message"}
@respx.mock @respx.mock
async def test_form_stream_permission_error( async def test_form_stream_permission_error(
hass: HomeAssistant, fakeimgbytes_png: bytes, user_flow: ConfigFlowResult hass: HomeAssistant, fakeimgbytes_png: bytes, user_flow: ConfigFlowResult
@ -905,23 +885,22 @@ async def test_options_only_stream(
@respx.mock @respx.mock
@pytest.mark.usefixtures("fakeimg_png") @pytest.mark.usefixtures("fakeimg_png")
async def test_form_options_stream_worker_error( async def test_form_options_permission_error(
hass: HomeAssistant, config_entry: MockConfigEntry hass: HomeAssistant, config_entry: MockConfigEntry
) -> None: ) -> None:
"""Test we handle a StreamWorkerError and pass the message through.""" """Test we handle a PermissionError and pass the message through."""
result = await hass.config_entries.options.async_init(config_entry.entry_id) result = await hass.config_entries.options.async_init(config_entry.entry_id)
with patch( with patch(
"homeassistant.components.generic.config_flow.create_stream", "homeassistant.components.generic.config_flow.create_stream",
side_effect=StreamWorkerError("Some message"), side_effect=PermissionError("Some message"),
): ):
result2 = await hass.config_entries.options.async_configure( result2 = await hass.config_entries.options.async_configure(
result["flow_id"], result["flow_id"],
TESTDATA, TESTDATA,
) )
assert result2["type"] is FlowResultType.FORM assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"stream_source": "unknown_with_details"} assert result2["errors"] == {"stream_source": "stream_not_permitted"}
assert result2["description_placeholders"] == {"error": "Some message"}
@pytest.mark.usefixtures("fakeimg_png") @pytest.mark.usefixtures("fakeimg_png")

View File

@ -278,8 +278,19 @@ async def test_stream_timeout_after_stop(
await hass.async_block_till_done() await hass.async_block_till_done()
@pytest.mark.parametrize(
("exception"),
[
# pylint: disable-next=c-extension-no-member
(av.error.InvalidDataError(-2, "error")),
(av.HTTPBadRequestError(500, "error")),
],
)
async def test_stream_retries( async def test_stream_retries(
hass: HomeAssistant, setup_component, should_retry hass: HomeAssistant,
setup_component,
should_retry,
exception,
) -> None: ) -> None:
"""Test hls stream is retried on failure.""" """Test hls stream is retried on failure."""
# Setup demo HLS track # Setup demo HLS track
@ -309,8 +320,7 @@ async def test_stream_retries(
def av_open_side_effect(*args, **kwargs): def av_open_side_effect(*args, **kwargs):
hass.loop.call_soon_threadsafe(futures.pop().set_result, None) hass.loop.call_soon_threadsafe(futures.pop().set_result, None)
# pylint: disable-next=c-extension-no-member raise exception
raise av.error.InvalidDataError(-2, "error")
with ( with (
patch("av.open") as av_open, patch("av.open") as av_open,

View File

@ -1,24 +1,41 @@
"""Test stream init.""" """Test stream init."""
import logging import logging
from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import av import av
import pytest import pytest
from homeassistant.components.stream import ( from homeassistant.components.stream import (
CONF_PREFER_TCP,
SOURCE_TIMEOUT, SOURCE_TIMEOUT,
StreamClientError, StreamClientError,
StreamOpenClientError, StreamOpenClientError,
__name__ as stream_name, __name__ as stream_name,
_async_try_open_stream,
async_check_stream_client_error, async_check_stream_client_error,
create_stream,
) )
from homeassistant.components.stream.const import ATTR_PREFER_TCP
from homeassistant.const import EVENT_LOGGING_CHANGED from homeassistant.const import EVENT_LOGGING_CHANGED
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from .common import dynamic_stream_settings
async def test_stream_not_setup(hass: HomeAssistant, h264_video) -> None:
"""Test hls stream.
Purposefully not mocking anything here to test full
integration with the stream component.
"""
with pytest.raises(HomeAssistantError, match="Stream integration is not set up"):
create_stream(hass, "rtsp://foobar", {}, dynamic_stream_settings())
with pytest.raises(HomeAssistantError, match="Stream integration is not set up"):
await async_check_stream_client_error(hass, "rtsp://foobar")
async def test_log_levels( async def test_log_levels(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture hass: HomeAssistant, caplog: pytest.LogCaptureFixture
@ -66,6 +83,7 @@ async def test_log_levels(
async def test_check_open_stream_params(hass: HomeAssistant) -> None: async def test_check_open_stream_params(hass: HomeAssistant) -> None:
"""Test check open stream params.""" """Test check open stream params."""
await async_setup_component(hass, "stream", {"stream": {}})
container_mock = MagicMock() container_mock = MagicMock()
source = "rtsp://foobar" source = "rtsp://foobar"
@ -74,24 +92,19 @@ async def test_check_open_stream_params(hass: HomeAssistant) -> None:
await async_check_stream_client_error(hass, source) await async_check_stream_client_error(hass, source)
options = { options = {
"rtsp_flags": CONF_PREFER_TCP, "rtsp_flags": ATTR_PREFER_TCP,
"timeout": str(SOURCE_TIMEOUT), "stimeout": "5000000",
} }
open_mock.assert_called_once_with(source, options=options, timeout=5) open_mock.assert_called_once_with(source, options=options, timeout=SOURCE_TIMEOUT)
container_mock.close.assert_called_once() container_mock.close.assert_called_once()
container_mock.reset_mock() container_mock.reset_mock()
with patch("av.open", return_value=container_mock) as open_mock: with (
patch("av.open", return_value=container_mock) as open_mock,
pytest.raises(HomeAssistantError, match="Invalid stream options"),
):
await async_check_stream_client_error(hass, source, {"foo": "bar"}) await async_check_stream_client_error(hass, source, {"foo": "bar"})
options = {
"rtsp_flags": CONF_PREFER_TCP,
"timeout": str(SOURCE_TIMEOUT),
"foo": "bar",
}
open_mock.assert_called_once_with(source, options=options, timeout=5)
container_mock.close.assert_called_once()
@pytest.mark.parametrize( @pytest.mark.parametrize(
("error", "enum_result"), ("error", "enum_result"),
@ -121,13 +134,56 @@ async def test_try_open_stream_error(
hass: HomeAssistant, error: av.HTTPClientError, enum_result: StreamClientError hass: HomeAssistant, error: av.HTTPClientError, enum_result: StreamClientError
) -> None: ) -> None:
"""Test trying to open a stream.""" """Test trying to open a stream."""
oc_error: StreamOpenClientError | None = None await async_setup_component(hass, "stream", {"stream": {}})
with patch("av.open", side_effect=error): with (
try: patch("av.open", side_effect=error),
await _async_try_open_stream(hass, "rtsp://foobar") pytest.raises(StreamOpenClientError) as ex,
except StreamOpenClientError as ex: ):
oc_error = ex await async_check_stream_client_error(hass, "rtsp://foobar")
assert ex.value.error_code is enum_result
assert oc_error
assert oc_error.stream_client_error is enum_result @pytest.mark.parametrize(
("options", "expected_pyav_options"),
[
(
{},
{"rtsp_flags": "prefer_tcp", "stimeout": "5000000"},
),
(
{"rtsp_transport": "udp"},
{
"rtsp_flags": "prefer_tcp",
"rtsp_transport": "udp",
"stimeout": "5000000",
},
),
(
{"use_wallclock_as_timestamps": True},
{
"rtsp_flags": "prefer_tcp",
"stimeout": "5000000",
"use_wallclock_as_timestamps": "1",
},
),
],
)
async def test_convert_stream_options(
hass: HomeAssistant,
options: dict[str, Any],
expected_pyav_options: dict[str, Any],
) -> None:
"""Test stream options."""
await async_setup_component(hass, "stream", {"stream": {}})
container_mock = MagicMock()
source = "rtsp://foobar"
with patch("av.open", return_value=container_mock) as open_mock:
await async_check_stream_client_error(hass, source, options)
open_mock.assert_called_once_with(
source, options=expected_pyav_options, timeout=SOURCE_TIMEOUT
)
container_mock.close.assert_called_once()

View File

@ -41,6 +41,7 @@ from homeassistant.components.stream.const import (
TARGET_SEGMENT_DURATION_NON_LL_HLS, TARGET_SEGMENT_DURATION_NON_LL_HLS,
) )
from homeassistant.components.stream.core import Orientation, StreamSettings from homeassistant.components.stream.core import Orientation, StreamSettings
from homeassistant.components.stream.exceptions import StreamClientError
from homeassistant.components.stream.worker import ( from homeassistant.components.stream.worker import (
StreamEndedError, StreamEndedError,
StreamState, StreamState,
@ -341,7 +342,18 @@ async def async_decode_stream(
return py_av.capture_buffer return py_av.capture_buffer
async def test_stream_open_fails(hass: HomeAssistant) -> None: @pytest.mark.parametrize(
("exception", "error_code"),
[
# pylint: disable-next=c-extension-no-member
(av.error.InvalidDataError(-2, "error"), StreamClientError.Other),
(av.HTTPBadRequestError(400, ""), StreamClientError.BadRequest),
(av.HTTPUnauthorizedError(401, ""), StreamClientError.Unauthorized),
],
)
async def test_stream_open_fails(
hass: HomeAssistant, exception: Exception, error_code: StreamClientError
) -> None:
"""Test failure on stream open.""" """Test failure on stream open."""
stream = Stream( stream = Stream(
hass, hass,
@ -352,12 +364,11 @@ async def test_stream_open_fails(hass: HomeAssistant) -> None:
) )
stream.add_provider(HLS_PROVIDER) stream.add_provider(HLS_PROVIDER)
with patch("av.open") as av_open: with patch("av.open") as av_open:
# pylint: disable-next=c-extension-no-member av_open.side_effect = exception
av_open.side_effect = av.error.InvalidDataError(-2, "error") with pytest.raises(StreamWorkerError) as err:
with pytest.raises(StreamWorkerError):
run_worker(hass, stream, STREAM_SOURCE) run_worker(hass, stream, STREAM_SOURCE)
await hass.async_block_till_done()
av_open.assert_called_once() av_open.assert_called_once()
assert err.value.error_code == error_code
async def test_stream_worker_success(hass: HomeAssistant) -> None: async def test_stream_worker_success(hass: HomeAssistant) -> None:

View File

@ -346,7 +346,8 @@ async def test_camera_image_auth_error(
patch( patch(
"homeassistant.components.stream.async_check_stream_client_error", "homeassistant.components.stream.async_check_stream_client_error",
side_effect=stream.StreamOpenClientError( side_effect=stream.StreamOpenClientError(
stream_client_error=stream.StreamClientError.Unauthorized "Request was unauthorized",
error_code=stream.StreamClientError.Unauthorized,
), ),
), ),
pytest.raises(HomeAssistantError), pytest.raises(HomeAssistantError),

View File

@ -768,7 +768,7 @@ async def test_manual_camera(
patch( patch(
"homeassistant.components.stream.async_check_stream_client_error", "homeassistant.components.stream.async_check_stream_client_error",
side_effect=stream.StreamOpenClientError( side_effect=stream.StreamOpenClientError(
stream_client_error=stream.StreamClientError.NotFound "Stream was not found", error_code=stream.StreamClientError.NotFound
), ),
), ),
): ):
@ -791,7 +791,8 @@ async def test_manual_camera(
patch( patch(
"homeassistant.components.stream.async_check_stream_client_error", "homeassistant.components.stream.async_check_stream_client_error",
side_effect=stream.StreamOpenClientError( side_effect=stream.StreamOpenClientError(
stream_client_error=stream.StreamClientError.Unauthorized "Request is unauthorized",
error_code=stream.StreamClientError.Unauthorized,
), ),
), ),
): ):
@ -835,7 +836,7 @@ async def test_manual_camera(
[ [
pytest.param( pytest.param(
stream.StreamOpenClientError( stream.StreamOpenClientError(
stream_client_error=stream.StreamClientError.NotFound "Stream was not found", error_code=stream.StreamClientError.NotFound
), ),
id="open_client_error", id="open_client_error",
), ),