Skip to content
19 changes: 8 additions & 11 deletions homeassistant/components/generic/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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__)
Expand All @@ -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),
}
)

Expand Down Expand Up @@ -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
Expand Down
17 changes: 9 additions & 8 deletions homeassistant/components/generic/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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__)
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 0 additions & 12 deletions homeassistant/components/generic/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 3 additions & 8 deletions homeassistant/components/onvif/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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


Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion homeassistant/components/onvif/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,7 +28,6 @@
ATTR_SPEED,
ATTR_TILT,
ATTR_ZOOM,
CONF_RTSP_TRANSPORT,
CONF_SNAPSHOT_AUTH,
CONTINUOUS_MOVE,
DIR_DOWN,
Expand Down
15 changes: 4 additions & 11 deletions homeassistant/components/onvif/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"
Expand Down Expand Up @@ -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),
}
),
)
3 changes: 0 additions & 3 deletions homeassistant/components/onvif/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
43 changes: 37 additions & 6 deletions homeassistant/components/stream/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
11 changes: 11 additions & 0 deletions homeassistant/components/stream/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 4 additions & 0 deletions homeassistant/components/stream/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions tests/components/generic/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions tests/components/onvif/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
}