diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 197890efad3a6..4886e3a0693f0 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -12,6 +12,11 @@ Camera, CameraEntityFeature, ) +from homeassistant.components.stream.const import ( + CONF_RTSP_TRANSPORT, + CONF_USE_WALLCLOCK_AS_TIMESTAMPS, + RTSP_TRANSPORTS, +) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_AUTHENTICATION, @@ -34,14 +39,10 @@ CONF_CONTENT_TYPE, CONF_FRAMERATE, CONF_LIMIT_REFETCH_TO_URL_CHANGE, - CONF_RTSP_TRANSPORT, CONF_STILL_IMAGE_URL, CONF_STREAM_SOURCE, - CONF_USE_WALLCLOCK_AS_TIMESTAMPS, DEFAULT_NAME, - FFMPEG_OPTION_MAP, GET_IMAGE_TIMEOUT, - RTSP_TRANSPORTS, ) _LOGGER = logging.getLogger(__name__) @@ -63,7 +64,7 @@ cv.small_float, cv.positive_int ), vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - vol.Optional(CONF_RTSP_TRANSPORT): vol.In(RTSP_TRANSPORTS.keys()), + vol.Optional(CONF_RTSP_TRANSPORT): vol.In(RTSP_TRANSPORTS), } ) @@ -157,14 +158,10 @@ def __init__(self, hass, device_info, identifier, title): self.content_type = device_info[CONF_CONTENT_TYPE] self.verify_ssl = device_info[CONF_VERIFY_SSL] if device_info.get(CONF_RTSP_TRANSPORT): - self.stream_options[FFMPEG_OPTION_MAP[CONF_RTSP_TRANSPORT]] = device_info[ - CONF_RTSP_TRANSPORT - ] + self.stream_options[CONF_RTSP_TRANSPORT] = device_info[CONF_RTSP_TRANSPORT] self._auth = generate_auth(device_info) if device_info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): - self.stream_options[ - FFMPEG_OPTION_MAP[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] - ] = "1" + self.stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True self._last_url = None self._last_image = None diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 0a49393d9ccfd..bf9499c07df01 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -16,7 +16,12 @@ import voluptuous as vol import yarl -from homeassistant.components.stream.const import SOURCE_TIMEOUT +from homeassistant.components.stream.const import ( + CONF_RTSP_TRANSPORT, + CONF_USE_WALLCLOCK_AS_TIMESTAMPS, + RTSP_TRANSPORTS, + SOURCE_TIMEOUT, +) from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import ( CONF_AUTHENTICATION, @@ -38,15 +43,11 @@ CONF_CONTENT_TYPE, CONF_FRAMERATE, CONF_LIMIT_REFETCH_TO_URL_CHANGE, - CONF_RTSP_TRANSPORT, CONF_STILL_IMAGE_URL, CONF_STREAM_SOURCE, - CONF_USE_WALLCLOCK_AS_TIMESTAMPS, DEFAULT_NAME, DOMAIN, - FFMPEG_OPTION_MAP, GET_IMAGE_TIMEOUT, - RTSP_TRANSPORTS, ) _LOGGER = logging.getLogger(__name__) @@ -200,16 +201,16 @@ async def async_test_stream(hass, info) -> dict[str, str]: # For RTSP streams, prefer TCP. This code is duplicated from # homeassistant.components.stream.__init__.py:create_stream() # It may be possible & better to call create_stream() directly. - stream_options: dict[str, str] = {} + stream_options: dict[str, bool | str] = {} if isinstance(stream_source, str) and stream_source[:7] == "rtsp://": stream_options = { "rtsp_flags": "prefer_tcp", "stimeout": "5000000", } if rtsp_transport := info.get(CONF_RTSP_TRANSPORT): - stream_options[FFMPEG_OPTION_MAP[CONF_RTSP_TRANSPORT]] = rtsp_transport + stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport if info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): - stream_options[FFMPEG_OPTION_MAP[CONF_USE_WALLCLOCK_AS_TIMESTAMPS]] = "1" + stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True _LOGGER.debug("Attempting to open stream %s", stream_source) container = await hass.async_add_executor_job( partial( diff --git a/homeassistant/components/generic/const.py b/homeassistant/components/generic/const.py index 8ae5f16c4c49c..eb0d81d493cfc 100644 --- a/homeassistant/components/generic/const.py +++ b/homeassistant/components/generic/const.py @@ -7,18 +7,6 @@ CONF_STILL_IMAGE_URL = "still_image_url" CONF_STREAM_SOURCE = "stream_source" CONF_FRAMERATE = "framerate" -CONF_RTSP_TRANSPORT = "rtsp_transport" -CONF_USE_WALLCLOCK_AS_TIMESTAMPS = "use_wallclock_as_timestamps" -FFMPEG_OPTION_MAP = { - CONF_RTSP_TRANSPORT: "rtsp_transport", - CONF_USE_WALLCLOCK_AS_TIMESTAMPS: "use_wallclock_as_timestamps", -} -RTSP_TRANSPORTS = { - "tcp": "TCP", - "udp": "UDP", - "udp_multicast": "UDP Multicast", - "http": "HTTP", -} GET_IMAGE_TIMEOUT = 10 DEFAULT_USERNAME = None diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 8956f0ae2f948..d966549a36f7f 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -2,6 +2,7 @@ from onvif.exceptions import ONVIFAuthError, ONVIFError, ONVIFTimeoutError from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS +from homeassistant.components.stream.const import CONF_RTSP_TRANSPORT, RTSP_TRANSPORTS from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, @@ -12,13 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import ( - CONF_RTSP_TRANSPORT, - CONF_SNAPSHOT_AUTH, - DEFAULT_ARGUMENTS, - DOMAIN, - RTSP_TRANS_PROTOCOLS, -) +from .const import CONF_SNAPSHOT_AUTH, DEFAULT_ARGUMENTS, DOMAIN from .device import ONVIFDevice @@ -99,7 +94,7 @@ async def async_populate_options(hass, entry): """Populate default options for device.""" options = { CONF_EXTRA_ARGUMENTS: DEFAULT_ARGUMENTS, - CONF_RTSP_TRANSPORT: RTSP_TRANS_PROTOCOLS[0], + CONF_RTSP_TRANSPORT: next(iter(RTSP_TRANSPORTS)), } hass.config_entries.async_update_entry(entry, options=options) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 3475df241b069..6b61a37eb168f 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -9,6 +9,7 @@ from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS, get_ffmpeg_manager +from homeassistant.components.stream.const import CONF_RTSP_TRANSPORT from homeassistant.config_entries import ConfigEntry from homeassistant.const import HTTP_BASIC_AUTHENTICATION from homeassistant.core import HomeAssistant @@ -27,7 +28,6 @@ ATTR_SPEED, ATTR_TILT, ATTR_ZOOM, - CONF_RTSP_TRANSPORT, CONF_SNAPSHOT_AUTH, CONTINUOUS_MOVE, DIR_DOWN, diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index c4579702675a0..894f2fee3dfda 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -13,6 +13,7 @@ from homeassistant import config_entries from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS +from homeassistant.components.stream import CONF_RTSP_TRANSPORT, RTSP_TRANSPORTS from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -22,15 +23,7 @@ ) from homeassistant.core import callback -from .const import ( - CONF_DEVICE_ID, - CONF_RTSP_TRANSPORT, - DEFAULT_ARGUMENTS, - DEFAULT_PORT, - DOMAIN, - LOGGER, - RTSP_TRANS_PROTOCOLS, -) +from .const import CONF_DEVICE_ID, DEFAULT_ARGUMENTS, DEFAULT_PORT, DOMAIN, LOGGER from .device import get_device CONF_MANUAL_INPUT = "Manually configure ONVIF device" @@ -294,9 +287,9 @@ async def async_step_onvif_devices(self, user_input=None): vol.Optional( CONF_RTSP_TRANSPORT, default=self.config_entry.options.get( - CONF_RTSP_TRANSPORT, RTSP_TRANS_PROTOCOLS[0] + CONF_RTSP_TRANSPORT, next(iter(RTSP_TRANSPORTS)) ), - ): vol.In(RTSP_TRANS_PROTOCOLS), + ): vol.In(RTSP_TRANSPORTS), } ), ) diff --git a/homeassistant/components/onvif/const.py b/homeassistant/components/onvif/const.py index 3a2e802a5a0b9..410088f28df2f 100644 --- a/homeassistant/components/onvif/const.py +++ b/homeassistant/components/onvif/const.py @@ -9,11 +9,8 @@ DEFAULT_ARGUMENTS = "-pred 1" CONF_DEVICE_ID = "deviceid" -CONF_RTSP_TRANSPORT = "rtsp_transport" CONF_SNAPSHOT_AUTH = "snapshot_auth" -RTSP_TRANS_PROTOCOLS = ["tcp", "udp", "udp_multicast", "http"] - ATTR_PAN = "pan" ATTR_TILT = "tilt" ATTR_ZOOM = "zoom" diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index abaf367486d29..fbdfd97f9b2eb 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -23,7 +23,7 @@ import threading import time from types import MappingProxyType -from typing import Any, cast +from typing import Any, Final, cast import voluptuous as vol @@ -39,12 +39,15 @@ ATTR_STREAMS, CONF_LL_HLS, CONF_PART_DURATION, + CONF_RTSP_TRANSPORT, CONF_SEGMENT_DURATION, + CONF_USE_WALLCLOCK_AS_TIMESTAMPS, DOMAIN, HLS_PROVIDER, MAX_SEGMENTS, OUTPUT_IDLE_TIMEOUT, RECORDER_PROVIDER, + RTSP_TRANSPORTS, SEGMENT_DURATION_ADJUSTER, STREAM_RESTART_INCREMENT, STREAM_RESTART_RESET_TIME, @@ -72,28 +75,32 @@ def redact_credentials(data: str) -> str: def create_stream( hass: HomeAssistant, stream_source: str, - options: dict[str, str], + options: dict[str, Any], stream_label: str | None = None, ) -> Stream: """Create a stream with the specified identfier based on the source url. The stream_source is typically an rtsp url (though any url accepted by ffmpeg is fine) and - options are passed into pyav / ffmpeg as options. + options (see STREAM_OPTIONS_SCHEMA) are converted and passed into pyav / ffmpeg. The stream_label is a string used as an additional message in logging. """ if DOMAIN not in hass.config.components: raise HomeAssistantError("Stream integration is not set up.") + # Convert extra stream options into PyAV options + pyav_options = convert_stream_options(options) # For RTSP streams, prefer TCP if isinstance(stream_source, str) and stream_source[:7] == "rtsp://": - options = { + pyav_options = { "rtsp_flags": "prefer_tcp", "stimeout": "5000000", - **options, + **pyav_options, } - stream = Stream(hass, stream_source, options=options, stream_label=stream_label) + stream = Stream( + hass, stream_source, options=pyav_options, stream_label=stream_label + ) hass.data[DOMAIN][ATTR_STREAMS].append(stream) return stream @@ -464,3 +471,27 @@ def get_diagnostics(self) -> dict[str, Any]: def _should_retry() -> bool: """Return true if worker failures should be retried, for disabling during tests.""" return True + + +STREAM_OPTIONS_SCHEMA: Final = vol.Schema( + { + vol.Optional(CONF_RTSP_TRANSPORT): vol.In(RTSP_TRANSPORTS), + vol.Optional(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): bool, + } +) + + +def convert_stream_options(stream_options: dict[str, Any]) -> dict[str, str]: + """Convert options from stream options into PyAV options.""" + pyav_options: dict[str, str] = {} + try: + STREAM_OPTIONS_SCHEMA(stream_options) + except vol.Invalid as exc: + raise HomeAssistantError("Invalid stream options") from exc + + if rtsp_transport := stream_options.get(CONF_RTSP_TRANSPORT): + 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 diff --git a/homeassistant/components/stream/const.py b/homeassistant/components/stream/const.py index 50ae43df0d0fb..f8c9ba85d59f6 100644 --- a/homeassistant/components/stream/const.py +++ b/homeassistant/components/stream/const.py @@ -42,3 +42,14 @@ CONF_LL_HLS = "ll_hls" CONF_PART_DURATION = "part_duration" CONF_SEGMENT_DURATION = "segment_duration" + +CONF_PREFER_TCP = "prefer_tcp" +CONF_RTSP_TRANSPORT = "rtsp_transport" +# The first dict entry below may be used as the default when populating options +RTSP_TRANSPORTS = { + "tcp": "TCP", + "udp": "UDP", + "udp_multicast": "UDP Multicast", + "http": "HTTP", +} +CONF_USE_WALLCLOCK_AS_TIMESTAMPS = "use_wallclock_as_timestamps" diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index bde5ab0fb056d..f8d12c1cb4497 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -452,6 +452,10 @@ def stream_worker( ) -> None: """Handle consuming streams.""" + if av.library_versions["libavformat"][0] >= 59 and "stimeout" in options: + # the stimeout option was renamed to timeout as of ffmpeg 5.0 + options["timeout"] = options["stimeout"] + del options["stimeout"] try: container = av.open(source, options=options, timeout=SOURCE_TIMEOUT) except av.AVError as err: diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index dd53cb8548e8e..82cf41c6e9186 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -15,12 +15,14 @@ CONF_CONTENT_TYPE, CONF_FRAMERATE, CONF_LIMIT_REFETCH_TO_URL_CHANGE, - CONF_RTSP_TRANSPORT, CONF_STILL_IMAGE_URL, CONF_STREAM_SOURCE, - CONF_USE_WALLCLOCK_AS_TIMESTAMPS, DOMAIN, ) +from homeassistant.components.stream.const import ( + CONF_RTSP_TRANSPORT, + CONF_USE_WALLCLOCK_AS_TIMESTAMPS, +) from homeassistant.const import ( CONF_AUTHENTICATION, CONF_NAME, diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py index 0760ead2ba13a..1e4dabfe7cf15 100644 --- a/tests/components/onvif/test_config_flow.py +++ b/tests/components/onvif/test_config_flow.py @@ -323,12 +323,12 @@ async def test_option_flow(hass): result["flow_id"], user_input={ config_flow.CONF_EXTRA_ARGUMENTS: "", - config_flow.CONF_RTSP_TRANSPORT: config_flow.RTSP_TRANS_PROTOCOLS[1], + config_flow.CONF_RTSP_TRANSPORT: list(config_flow.RTSP_TRANSPORTS)[1], }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["data"] == { config_flow.CONF_EXTRA_ARGUMENTS: "", - config_flow.CONF_RTSP_TRANSPORT: config_flow.RTSP_TRANS_PROTOCOLS[1], + config_flow.CONF_RTSP_TRANSPORT: list(config_flow.RTSP_TRANSPORTS)[1], }