"""The go2rtc component.""" from dataclasses import dataclass import logging from secrets import token_hex import shutil from tempfile import mkdtemp from typing import override from aiohttp import ClientSession, UnixConnector, encode_basic_auth from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError from awesomeversion import AwesomeVersion from go2rtc_client import Go2RtcRestClient from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError from go2rtc_client.ws import ( Go2RtcWsClient, ReceiveMessages, WebRTCAnswer, WebRTCCandidate, WebRTCOffer, WsError, ) import voluptuous as vol from webrtc_models import RTCIceCandidateInit from homeassistant.components.camera import ( Camera, CameraWebRTCProvider, WebRTCAnswer as HAWebRTCAnswer, WebRTCCandidate as HAWebRTCCandidate, WebRTCError, WebRTCMessage, WebRTCSendMessage, async_register_webrtc_provider, get_dynamic_camera_stream_settings, ) from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOMAIN from homeassistant.components.stream import Orientation from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry from homeassistant.const import ( CONF_PASSWORD, CONF_URL, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import ( config_validation as cv, discovery_flow, issue_registry as ir, ) from homeassistant.helpers.aiohttp_client import ( async_create_clientsession, async_get_clientsession, ) from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey from homeassistant.util.package import is_docker_env from .const import ( CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN, HA_MANAGED_URL, RECOMMENDED_VERSION, ) from .server import Server from .util import get_camera_identifier, get_go2rtc_unix_socket_path _LOGGER = logging.getLogger(__name__) _FFMPEG = "ffmpeg" _AUTH = "auth" def _validate_auth(config: dict) -> dict: """Validate username/password only when URL is configured or debug UI enabled.""" auth_exists = CONF_USERNAME in config debug_ui_enabled = config.get(CONF_DEBUG_UI, False) if debug_ui_enabled and not auth_exists: raise vol.Invalid("Username and password must be set when debug_ui is true") if auth_exists and CONF_URL not in config and not debug_ui_enabled: raise vol.Invalid( "Username and password can only be set when a URL is" " configured or debug_ui is true" ) return config CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( vol.Schema( { vol.Exclusive(CONF_URL, DOMAIN, DEBUG_UI_URL_MESSAGE): cv.url, vol.Exclusive( CONF_DEBUG_UI, DOMAIN, DEBUG_UI_URL_MESSAGE ): cv.boolean, vol.Inclusive(CONF_USERNAME, _AUTH): vol.All( cv.string, vol.Length(min=1) ), vol.Inclusive(CONF_PASSWORD, _AUTH): vol.All( cv.string, vol.Length(min=1) ), } ), _validate_auth, ) }, extra=vol.ALLOW_EXTRA, ) _DATA_GO2RTC: HassKey[Go2RtcConfig] = HassKey(DOMAIN) _RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError) type Go2RtcConfigEntry = ConfigEntry[WebRTCProvider] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up WebRTC.""" url: str | None = None username: str | None = None password: str | None = None if DOMAIN not in config and DEFAULT_CONFIG_DOMAIN not in config: await _remove_go2rtc_entries(hass) return True domain_config = config.get(DOMAIN, {}) username = domain_config.get(CONF_USERNAME) password = domain_config.get(CONF_PASSWORD) if not (configured_by_user := DOMAIN in config) or not ( url := domain_config.get(CONF_URL) ): if not is_docker_env(): if not configured_by_user: # Remove config entry if it exists await _remove_go2rtc_entries(hass) return True _LOGGER.warning("Go2rtc URL required in non-docker installs") return False if not (binary := await _get_binary(hass)): _LOGGER.error("Could not find go2rtc docker binary") return False # Generate random credentials when not provided to secure the server if not username or not password: username = token_hex() password = token_hex() _LOGGER.debug("Generated random credentials for go2rtc server") # HA will manage the binary temp_dir = mkdtemp(prefix="go2rtc-") # Manually created session (not using the helper) needs to be closed manually # See on_stop listener below session = ClientSession( connector=UnixConnector(path=get_go2rtc_unix_socket_path(temp_dir)), headers={"Authorization": encode_basic_auth(username, password)}, ) server = Server( hass, binary, session, enable_ui=domain_config.get(CONF_DEBUG_UI, False), username=username, password=password, working_dir=temp_dir, ) try: await server.start() except Exception: _LOGGER.warning("Could not start go2rtc server", exc_info=True) await session.close() return False async def on_stop(event: Event) -> None: await server.stop() await session.close() hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) url = HA_MANAGED_URL elif username and password: session = async_create_clientsession( hass, headers={"Authorization": encode_basic_auth(username, password)} ) else: session = async_get_clientsession(hass) hass.data[_DATA_GO2RTC] = Go2RtcConfig(url, session) discovery_flow.async_create_flow( hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={} ) return True async def _remove_go2rtc_entries(hass: HomeAssistant) -> None: """Remove go2rtc config entries, if any.""" for entry in hass.config_entries.async_entries(DOMAIN): await hass.config_entries.async_remove(entry.entry_id) async def async_setup_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bool: """Set up go2rtc from a config entry.""" config = hass.data[_DATA_GO2RTC] url = config.url session = config.session client = Go2RtcRestClient(session, url) # Validate the server URL try: version = await client.validate_server_version() if version < AwesomeVersion(RECOMMENDED_VERSION): ir.async_create_issue( hass, DOMAIN, "recommended_version", is_fixable=False, is_persistent=False, severity=ir.IssueSeverity.WARNING, translation_key="recommended_version", translation_placeholders={ "recommended_version": RECOMMENDED_VERSION, "current_version": str(version), }, ) except Go2RtcClientError as err: if isinstance(err.__cause__, _RETRYABLE_ERRORS): raise ConfigEntryNotReady( f"Could not connect to go2rtc instance on {url}" ) from err _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) return False except Go2RtcVersionError as err: raise ConfigEntryNotReady( f"The go2rtc server version is not supported, {err}" ) from err except Exception as err: # noqa: BLE001 _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) return False provider = entry.runtime_data = WebRTCProvider(hass, url, session, client) await provider.initialize() entry.async_on_unload(async_register_webrtc_provider(hass, provider)) return True async def async_unload_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bool: """Unload a go2rtc config entry.""" await entry.runtime_data.teardown() return True async def _get_binary(hass: HomeAssistant) -> str | None: """Return the binary path if found.""" return await hass.async_add_executor_job(shutil.which, "go2rtc") @dataclass(frozen=True) class _SessionInfo: """Session info.""" ws_client: Go2RtcWsClient camera: Camera class WebRTCProvider(CameraWebRTCProvider): """WebRTC provider.""" def __init__( self, hass: HomeAssistant, url: str, session: ClientSession, rest_client: Go2RtcRestClient, ) -> None: """Initialize the WebRTC provider.""" self._hass = hass self._url = url self._session = session self._rest_client = rest_client self._sessions: dict[str, _SessionInfo] = {} self._supported_schemes: set[str] = set() @property @override def domain(self) -> str: """Return the integration domain of the provider.""" return DOMAIN async def initialize(self) -> None: """Initialize the provider.""" self._supported_schemes = await self._rest_client.schemes.list() @callback @override def async_is_supported(self, stream_source: str) -> bool: """Return if this provider is supports the Camera as source.""" return stream_source.partition(":")[0] in self._supported_schemes @override async def async_handle_async_webrtc_offer( self, camera: Camera, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage, ) -> None: """Handle the WebRTC offer and return the answer via the provided callback.""" try: await self._update_stream_source(camera) except HomeAssistantError as err: send_message(WebRTCError("go2rtc_webrtc_offer_failed", str(err))) return ws_client = Go2RtcWsClient( self._session, self._url, source=get_camera_identifier(camera) ) self._sessions[session_id] = _SessionInfo( ws_client=ws_client, camera=camera, ) @callback def on_messages(message: ReceiveMessages) -> None: """Handle messages.""" value: WebRTCMessage match message: case WebRTCCandidate(): value = HAWebRTCCandidate(RTCIceCandidateInit(message.candidate)) case WebRTCAnswer(): value = HAWebRTCAnswer(message.sdp) case WsError(): value = WebRTCError("go2rtc_webrtc_offer_failed", message.error) send_message(value) ws_client.subscribe(on_messages) config = camera.async_get_webrtc_client_configuration() await ws_client.send(WebRTCOffer(offer_sdp, config.configuration.ice_servers)) @override async def async_on_webrtc_candidate( self, session_id: str, candidate: RTCIceCandidateInit ) -> None: """Handle the WebRTC candidate.""" if session_info := self._sessions.get(session_id): await session_info.ws_client.send(WebRTCCandidate(candidate.candidate)) else: _LOGGER.debug("Unknown session %s. Ignoring candidate", session_id) @callback @override def async_close_session(self, session_id: str) -> None: """Close the session.""" if session_info := self._sessions.pop(session_id, None): self._hass.async_create_task(session_info.ws_client.close()) @override async def async_get_image( self, camera: Camera, width: int | None = None, height: int | None = None, ) -> bytes | None: """Get an image from the camera.""" await self._update_stream_source(camera) return await self._rest_client.get_jpeg_snapshot( get_camera_identifier(camera), width, height ) async def _update_stream_source(self, camera: Camera) -> None: """Update the stream source in go2rtc config if needed.""" if not (stream_source := await camera.stream_source()): await self._close_camera_sessions(camera) raise HomeAssistantError("Camera has no stream source") if camera.platform.platform_name == "generic": # This is a workaround to use ffmpeg for generic cameras # A proper fix will be added in the future together # with supporting multiple streams per camera stream_source = "ffmpeg:" + stream_source if not self.async_is_supported(stream_source): await self._close_camera_sessions(camera) raise HomeAssistantError("Stream source is not supported by go2rtc") camera_prefs = await get_dynamic_camera_stream_settings( self._hass, camera.entity_id ) if camera_prefs.orientation is not Orientation.NO_TRANSFORM: # Camera orientation manually set by user if not stream_source.startswith(_FFMPEG): stream_source = _FFMPEG + ":" + stream_source stream_source += "#video=h264#audio=copy" match camera_prefs.orientation: case Orientation.MIRROR: stream_source += "#raw=-vf hflip" case Orientation.ROTATE_180: stream_source += "#rotate=180" case Orientation.FLIP: stream_source += "#raw=-vf vflip" case Orientation.ROTATE_LEFT_AND_FLIP: # Cannot use any filter when using raw one stream_source += "#raw=-vf transpose=2,vflip" case Orientation.ROTATE_LEFT: stream_source += "#rotate=-90" case Orientation.ROTATE_RIGHT_AND_FLIP: # Cannot use any filter when using raw one stream_source += "#raw=-vf transpose=1,vflip" case Orientation.ROTATE_RIGHT: stream_source += "#rotate=90" streams = await self._rest_client.streams.list() identifier = get_camera_identifier(camera) if (stream := streams.get(identifier)) is None or not any( stream_source == producer.url for producer in stream.producers ): await self._rest_client.streams.add( identifier, [ stream_source, # We are setting any ffmpeg rtsp related logs to debug # Connection problems to the camera will be # logged by the first stream # Therefore setting it to debug will not hide any important logs f"ffmpeg:{identifier}#audio=opus#query=log_level=debug", ], ) async def _update_preload_stream(self, camera: Camera) -> None: identifier = get_camera_identifier(camera) camera_prefs = await get_dynamic_camera_stream_settings( self._hass, camera.entity_id ) preload_streams = await self._rest_client.preload.list() if camera_prefs.preload_stream == (identifier in preload_streams): return if camera_prefs.preload_stream: # We need to first add the stream source otherwise preload enabling will fail await self._update_stream_source(camera) await self._rest_client.preload.enable(identifier) else: await self._rest_client.preload.disable(identifier) async def _close_camera_sessions(self, camera: Camera) -> None: for session_id in list(self._sessions): session_info = self._sessions.get(session_id) if session_info is None or session_info.camera != camera: continue # Unregister before closing, as closing yields to the event loop del self._sessions[session_id] await session_info.ws_client.close() async def teardown(self) -> None: """Tear down the provider.""" while self._sessions: _, session_info = self._sessions.popitem() await session_info.ws_client.close() @override async def async_register_camera( self, camera: Camera, ) -> None: """Will be called when the provider is registered for a camera.""" await self._update_preload_stream(camera) @override async def async_unregister_camera( self, camera: Camera, ) -> None: """Will be called when the provider is unregistered for a camera.""" await self._close_camera_sessions(camera) identifier = get_camera_identifier(camera) if identifier in await self._rest_client.preload.list(): await self._rest_client.preload.disable(identifier) @override async def async_on_camera_prefs_update( self, camera: Camera, ) -> None: """Will be called when the camera preferences are updated.""" await self._update_preload_stream(camera) @dataclass class Go2RtcConfig: """Go2rtc configuration.""" url: str session: ClientSession