Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add go2rtc and extend camera integration for better WebRTC support #124410

Merged
merged 34 commits into from
Oct 3, 2024
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
167aa12
Add WebRTC
edenhaus Aug 22, 2024
05c69ac
Merge remote-tracking branch 'origin/dev' into edenhaus-webrtc
edenhaus Sep 11, 2024
a60cd67
Rename util to helper
edenhaus Sep 11, 2024
4b2d364
Renamed webrtc to go2rtc
edenhaus Sep 17, 2024
b0ff652
Add register_ice_server to register ice servers
edenhaus Sep 17, 2024
45fd958
Merge remote-tracking branch 'origin/dev' into edenhaus-webrtc
edenhaus Sep 17, 2024
5e488db
Fix
edenhaus Sep 17, 2024
9de2819
Some fixes
edenhaus Sep 17, 2024
aba0c4f
Use register_ice_server instead of custom ws endpoint
edenhaus Sep 17, 2024
278b817
minor improvements
edenhaus Sep 17, 2024
071d080
Disable default ice server
edenhaus Sep 17, 2024
8a8251c
Ci fixes and minor improvements
edenhaus Sep 17, 2024
858b6b5
Create data channel only if required
edenhaus Sep 18, 2024
1c0ac96
Fix tests
edenhaus Sep 18, 2024
fc3f027
Add binary to docker image and improve config flow
edenhaus Sep 19, 2024
e05e2f3
Improve async_get_webrtc_client_configuration
edenhaus Sep 20, 2024
57559db
Download go2rtc binary instead using docker image
edenhaus Sep 20, 2024
a0390ad
Fix docker
edenhaus Sep 20, 2024
845ace1
Merge remote-tracking branch 'origin/dev' into edenhaus-webrtc
edenhaus Sep 23, 2024
eda5166
Use lib
edenhaus Sep 27, 2024
1c22a8f
Merge remote-tracking branch 'origin/dev' into edenhaus-webrtc
edenhaus Sep 27, 2024
021cc6d
Add tests
edenhaus Sep 30, 2024
9c1af26
Fix requirements
edenhaus Sep 30, 2024
d05186f
Update supported streams
edenhaus Sep 30, 2024
fbfc3af
Fix tests
edenhaus Sep 30, 2024
25b8afb
Adjust manifest
edenhaus Sep 30, 2024
c6524e5
Add tests and minor fixes
edenhaus Sep 30, 2024
8f605b6
Apply suggestion
edenhaus Oct 1, 2024
beaafb7
Fix typo
edenhaus Oct 2, 2024
df6b7c2
Implement suggestions
edenhaus Oct 2, 2024
8094c10
Add init tests
edenhaus Oct 2, 2024
fb2fc7c
Apply suggestions from code review
edenhaus Oct 2, 2024
7ea36b8
Implement suggestion
edenhaus Oct 2, 2024
fb8e25d
Implement suggestions
edenhaus Oct 3, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,8 @@ build.json @home-assistant/supervisor
/tests/components/github/ @timmo001 @ludeeus
/homeassistant/components/glances/ @engrbm87
/tests/components/glances/ @engrbm87
/homeassistant/components/go2rtc/ @home-assistant/core
/tests/components/go2rtc/ @home-assistant/core
/homeassistant/components/goalzero/ @tkdrob
/tests/components/goalzero/ @tkdrob
/homeassistant/components/gogogate2/ @vangorra
Expand Down
15 changes: 15 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,19 @@ RUN \
# Home Assistant S6-Overlay
COPY rootfs /

# Needs to be redefined inside the FROM statement to be set for RUN commands
ARG BUILD_ARCH
# Get go2rtc binary
RUN \
case "${BUILD_ARCH}" in \
"aarch64") go2rtc_suffix='arm64' ;; \
"armhf") go2rtc_suffix='armv6' ;; \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.4/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version

WORKDIR /config
163 changes: 67 additions & 96 deletions homeassistant/components/camera/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import asyncio
import collections
from collections.abc import Awaitable, Callable, Iterable
from collections.abc import Awaitable, Callable
from contextlib import suppress
from dataclasses import asdict
from datetime import datetime, timedelta
Expand All @@ -14,7 +14,7 @@
import os
from random import SystemRandom
import time
from typing import Any, Final, cast, final
from typing import Any, Final, final

from aiohttp import hdrs, web
import attr
Expand Down Expand Up @@ -72,19 +72,30 @@
CONF_LOOKBACK,
DATA_CAMERA_PREFS,
DATA_COMPONENT,
DATA_RTSP_TO_WEB_RTC,
DOMAIN,
PREF_ORIENTATION,
PREF_PRELOAD_STREAM,
SERVICE_RECORD,
CameraState,
StreamType,
)
from .helper import get_camera_from_entity_id
from .img_util import scale_jpeg_camera_image
from .prefs import CameraPreferences, DynamicStreamSettings # noqa: F401
from .webrtc import (
DATA_ICE_SERVERS,
CameraWebRTCProvider,
RTCIceServer,
WebRTCClientConfiguration,
async_get_supported_providers,
async_register_rtsp_to_web_rtc_provider, # noqa: F401
register_ice_server,
ws_get_client_config,
)

_LOGGER = logging.getLogger(__name__)


ENTITY_ID_FORMAT: Final = DOMAIN + ".{}"
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
Expand Down Expand Up @@ -122,7 +133,6 @@
CameraEntityFeature.STREAM, "2025.1"
)

RTSP_PREFIXES = {"rtsp://", "rtsps://", "rtmp://"}

DEFAULT_CONTENT_TYPE: Final = "image/jpeg"
ENTITY_IMAGE_URL: Final = "/api/camera_proxy/{0}?token={1}"
Expand Down Expand Up @@ -161,7 +171,7 @@
@bind_hass
async def async_request_stream(hass: HomeAssistant, entity_id: str, fmt: str) -> str:
"""Request a stream for a camera entity."""
camera = _get_camera_from_entity_id(hass, entity_id)
camera = get_camera_from_entity_id(hass, entity_id)
return await _async_stream_endpoint_url(hass, camera, fmt)


Expand Down Expand Up @@ -219,7 +229,7 @@

width and height will be passed to the underlying camera.
"""
camera = _get_camera_from_entity_id(hass, entity_id)
camera = get_camera_from_entity_id(hass, entity_id)
return await _async_get_image(camera, timeout, width, height)


Expand All @@ -241,7 +251,7 @@
@bind_hass
async def async_get_stream_source(hass: HomeAssistant, entity_id: str) -> str | None:
"""Fetch the stream source for a camera entity."""
camera = _get_camera_from_entity_id(hass, entity_id)
camera = get_camera_from_entity_id(hass, entity_id)
return await camera.stream_source()


Expand All @@ -250,7 +260,7 @@
hass: HomeAssistant, request: web.Request, entity_id: str
) -> web.StreamResponse | None:
"""Fetch an mjpeg stream from a camera entity."""
camera = _get_camera_from_entity_id(hass, entity_id)
camera = get_camera_from_entity_id(hass, entity_id)

try:
stream = await camera.handle_async_mjpeg_stream(request)
Expand Down Expand Up @@ -317,69 +327,6 @@
return response


def _get_camera_from_entity_id(hass: HomeAssistant, entity_id: str) -> Camera:
"""Get camera component from entity_id."""
if (component := hass.data.get(DOMAIN)) is None:
raise HomeAssistantError("Camera integration not set up")

if (camera := component.get_entity(entity_id)) is None:
raise HomeAssistantError("Camera not found")

if not camera.is_on:
raise HomeAssistantError("Camera is off")

return cast(Camera, camera)


# An RtspToWebRtcProvider accepts these inputs:
# stream_source: The RTSP url
# offer_sdp: The WebRTC SDP offer
# stream_id: A unique id for the stream, used to update an existing source
# The output is the SDP answer, or None if the source or offer is not eligible.
# The Callable may throw HomeAssistantError on failure.
type RtspToWebRtcProviderType = Callable[[str, str, str], Awaitable[str | None]]


def async_register_rtsp_to_web_rtc_provider(
hass: HomeAssistant,
domain: str,
provider: RtspToWebRtcProviderType,
) -> Callable[[], None]:
"""Register an RTSP to WebRTC provider.

The first provider to satisfy the offer will be used.
"""
if DOMAIN not in hass.data:
raise ValueError("Unexpected state, camera not loaded")

def remove_provider() -> None:
if domain in hass.data[DATA_RTSP_TO_WEB_RTC]:
del hass.data[DATA_RTSP_TO_WEB_RTC]
hass.async_create_task(_async_refresh_providers(hass))

hass.data.setdefault(DATA_RTSP_TO_WEB_RTC, {})
hass.data[DATA_RTSP_TO_WEB_RTC][domain] = provider
hass.async_create_task(_async_refresh_providers(hass))
return remove_provider


async def _async_refresh_providers(hass: HomeAssistant) -> None:
"""Check all cameras for any state changes for registered providers."""

component = hass.data[DATA_COMPONENT]
await asyncio.gather(
*(camera.async_refresh_providers() for camera in component.entities)
)


def _async_get_rtsp_to_web_rtc_providers(
hass: HomeAssistant,
) -> Iterable[RtspToWebRtcProviderType]:
"""Return registered RTSP to WebRTC providers."""
providers = hass.data.get(DATA_RTSP_TO_WEB_RTC, {})
return providers.values()


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the camera component."""
component = hass.data[DATA_COMPONENT] = EntityComponent[Camera](
Expand All @@ -397,6 +344,7 @@
websocket_api.async_register_command(hass, ws_camera_web_rtc_offer)
websocket_api.async_register_command(hass, websocket_get_prefs)
websocket_api.async_register_command(hass, websocket_update_prefs)
websocket_api.async_register_command(hass, ws_get_client_config)

await component.async_setup(config)

Expand Down Expand Up @@ -452,6 +400,12 @@
SERVICE_RECORD, CAMERA_SERVICE_RECORD, async_handle_record_service
)

async def get_ice_server() -> RTCIceServer:
# The following servers will replaced before the next stable release with
# STUN server provided by Home Assistant. Used Google ones for testing purposes.
return RTCIceServer(urls="stun:stun.l.google.com:19302")

register_ice_server(hass, get_ice_server)
return True


Expand Down Expand Up @@ -507,7 +461,7 @@
self._warned_old_signature = False
self.async_update_token()
self._create_stream_lock: asyncio.Lock | None = None
self._rtsp_to_webrtc = False
self._webrtc_providers: list[CameraWebRTCProvider] = []

@cached_property
def entity_picture(self) -> str:
Expand Down Expand Up @@ -581,7 +535,7 @@
return self._attr_frontend_stream_type
if CameraEntityFeature.STREAM not in self.supported_features_compat:
return None
if self._rtsp_to_webrtc:
if self._webrtc_providers:
return StreamType.WEB_RTC
return StreamType.HLS

Expand Down Expand Up @@ -631,14 +585,12 @@

Integrations can override with a native WebRTC implementation.
"""
stream_source = await self.stream_source()
if not stream_source:
return None
for provider in _async_get_rtsp_to_web_rtc_providers(self.hass):
answer_sdp = await provider(stream_source, offer_sdp, self.entity_id)
if answer_sdp:
return answer_sdp
raise HomeAssistantError("WebRTC offer was not accepted by any providers")
for provider in self._webrtc_providers:
if answer := await provider.async_handle_web_rtc_offer(self, offer_sdp):
return answer
raise HomeAssistantError(

Check warning on line 591 in homeassistant/components/camera/__init__.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/camera/__init__.py#L591

Added line #L591 was not covered by tests
"WebRTC offer was not accepted by the supported providers"
)

def camera_image(
self, width: int | None = None, height: int | None = None
Expand Down Expand Up @@ -751,7 +703,7 @@
# Avoid calling async_refresh_providers() in here because it
# it will write state a second time since state is always
# written when an entity is added to hass.
self._rtsp_to_webrtc = await self._async_use_rtsp_to_webrtc()
self._webrtc_providers = await self._async_get_supported_webrtc_providers()

async def async_refresh_providers(self) -> None:
"""Determine if any of the registered providers are suitable for this entity.
Expand All @@ -761,22 +713,41 @@

Returns True if any state was updated (and needs to be written)
"""
old_state = self._rtsp_to_webrtc
self._rtsp_to_webrtc = await self._async_use_rtsp_to_webrtc()
if old_state != self._rtsp_to_webrtc:
old_providers = self._webrtc_providers
new_providers = await self._async_get_supported_webrtc_providers()
self._webrtc_providers = new_providers
if old_providers != new_providers:
self.async_write_ha_state()

async def _async_use_rtsp_to_webrtc(self) -> bool:
"""Determine if a WebRTC provider can be used for the camera."""
async def _async_get_supported_webrtc_providers(
self,
) -> list[CameraWebRTCProvider]:
"""Get the all providers that supports this camera."""
if CameraEntityFeature.STREAM not in self.supported_features_compat:
return False
if DATA_RTSP_TO_WEB_RTC not in self.hass.data:
return False
stream_source = await self.stream_source()
return any(
stream_source and stream_source.startswith(prefix)
for prefix in RTSP_PREFIXES
return []

return await async_get_supported_providers(self.hass, self)

@property
def webrtc_providers(self) -> list[CameraWebRTCProvider]:
"""Return the WebRTC providers."""
return self._webrtc_providers

Check warning on line 734 in homeassistant/components/camera/__init__.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/camera/__init__.py#L734

Added line #L734 was not covered by tests

async def _async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration:
"""Return the WebRTC client configuration adjustable per integration."""
return WebRTCClientConfiguration()

@final
async def async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration:
"""Return the WebRTC client configuration and extend it with the registered ice servers."""
config = await self._async_get_webrtc_client_configuration()

ice_servers = await asyncio.gather(
*[server() for server in self.hass.data.get(DATA_ICE_SERVERS, [])]
)
config.configuration.ice_servers.extend(ice_servers)

return config


class CameraView(HomeAssistantView):
Expand Down Expand Up @@ -885,7 +856,7 @@
"""
try:
entity_id = msg["entity_id"]
camera = _get_camera_from_entity_id(hass, entity_id)
camera = get_camera_from_entity_id(hass, entity_id)
url = await _async_stream_endpoint_url(hass, camera, fmt=msg["format"])
connection.send_result(msg["id"], {"url": url})
except HomeAssistantError as ex:
Expand Down Expand Up @@ -920,7 +891,7 @@
"""
entity_id = msg["entity_id"]
offer = msg["offer"]
camera = _get_camera_from_entity_id(hass, entity_id)
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
Expand Down
5 changes: 1 addition & 4 deletions homeassistant/components/camera/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,13 @@
if TYPE_CHECKING:
from homeassistant.helpers.entity_component import EntityComponent

from . import Camera, RtspToWebRtcProviderType
from . import Camera
from .prefs import CameraPreferences

DOMAIN: Final = "camera"
DATA_COMPONENT: HassKey[EntityComponent[Camera]] = HassKey(DOMAIN)

DATA_CAMERA_PREFS: HassKey[CameraPreferences] = HassKey("camera_prefs")
DATA_RTSP_TO_WEB_RTC: HassKey[dict[str, RtspToWebRtcProviderType]] = HassKey(
"rtsp_to_web_rtc"
)

PREF_PRELOAD_STREAM: Final = "preload_stream"
PREF_ORIENTATION: Final = "orientation"
Expand Down
4 changes: 2 additions & 2 deletions homeassistant/components/camera/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er

from . import _get_camera_from_entity_id
from .const import DOMAIN
from .helper import get_camera_from_entity_id


async def async_get_config_entry_diagnostics(
Expand All @@ -22,7 +22,7 @@ async def async_get_config_entry_diagnostics(
if entity.domain != DOMAIN:
continue
try:
camera = _get_camera_from_entity_id(hass, entity.entity_id)
camera = get_camera_from_entity_id(hass, entity.entity_id)
except HomeAssistantError:
continue
diagnostics[entity.entity_id] = (
Expand Down
28 changes: 28 additions & 0 deletions homeassistant/components/camera/helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Camera helper functions."""

from __future__ import annotations

from typing import TYPE_CHECKING

from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError

from .const import DATA_COMPONENT

if TYPE_CHECKING:
from . import Camera


def get_camera_from_entity_id(hass: HomeAssistant, entity_id: str) -> Camera:
"""Get camera component from entity_id."""
component = hass.data.get(DATA_COMPONENT)
if component is None:
raise HomeAssistantError("Camera integration not set up")

Check warning on line 20 in homeassistant/components/camera/helper.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/camera/helper.py#L20

Added line #L20 was not covered by tests

if (camera := component.get_entity(entity_id)) is None:
raise HomeAssistantError("Camera not found")

if not camera.is_on:
raise HomeAssistantError("Camera is off")

return camera
Loading