diff --git a/CODEOWNERS b/CODEOWNERS
index db7e174764781448621f70d6d4649ae70342df2c..36ed63175f25095b0d8b5fee0698da21bf1a7f04 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -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
diff --git a/Dockerfile b/Dockerfile
index 684357be82a9c4e1be2dd16ba586f318742cd198..44edbdf8e3e38ff5529276aeba2684eab000caba 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py
index e5bce1b545b98f9d357bf1f632f430a555d6d989..b78030318ccec1b03e962a20137bac95015670a3 100644
--- a/homeassistant/components/camera/__init__.py
+++ b/homeassistant/components/camera/__init__.py
@@ -4,7 +4,7 @@ from __future__ import annotations
 
 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
@@ -14,7 +14,7 @@ import logging
 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
@@ -72,7 +72,6 @@ from .const import (  # noqa: F401
     CONF_LOOKBACK,
     DATA_CAMERA_PREFS,
     DATA_COMPONENT,
-    DATA_RTSP_TO_WEB_RTC,
     DOMAIN,
     PREF_ORIENTATION,
     PREF_PRELOAD_STREAM,
@@ -80,11 +79,23 @@ from .const import (  # noqa: F401
     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
@@ -122,7 +133,6 @@ _DEPRECATED_SUPPORT_STREAM: Final = DeprecatedConstantEnum(
     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}"
@@ -161,7 +171,7 @@ class Image:
 @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)
 
 
@@ -219,7 +229,7 @@ async def async_get_image(
 
     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)
 
 
@@ -241,7 +251,7 @@ async def _async_get_stream_image(
 @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()
 
 
@@ -250,7 +260,7 @@ async def async_get_mjpeg_stream(
     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)
@@ -317,69 +327,6 @@ async def async_get_still_stream(
     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](
@@ -397,6 +344,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
     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)
 
@@ -452,6 +400,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
         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
 
 
@@ -507,7 +461,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
         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:
@@ -581,7 +535,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
             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
 
@@ -631,14 +585,12 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
 
         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(
+            "WebRTC offer was not accepted by the supported providers"
+        )
 
     def camera_image(
         self, width: int | None = None, height: int | None = None
@@ -751,7 +703,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
         # 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.
@@ -761,22 +713,41 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
 
         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
+
+    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):
@@ -885,7 +856,7 @@ async def ws_camera_stream(
     """
     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:
@@ -920,7 +891,7 @@ async def ws_camera_web_rtc_offer(
     """
     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"],
diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py
index 1286e0f39764b9b7ed4af2054aa9192820e3ba7a..7e4633d410a2ebc53fe7b0ff6f6a83264f0f4487 100644
--- a/homeassistant/components/camera/const.py
+++ b/homeassistant/components/camera/const.py
@@ -17,16 +17,13 @@ from homeassistant.util.hass_dict import HassKey
 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"
diff --git a/homeassistant/components/camera/diagnostics.py b/homeassistant/components/camera/diagnostics.py
index 1edda5079b4c3e05368a057e990a170504a77d75..3408ab3a0af6d2aeb581fcdebc74058a9fa25c51 100644
--- a/homeassistant/components/camera/diagnostics.py
+++ b/homeassistant/components/camera/diagnostics.py
@@ -7,8 +7,8 @@ from homeassistant.core import HomeAssistant
 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(
@@ -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] = (
diff --git a/homeassistant/components/camera/helper.py b/homeassistant/components/camera/helper.py
new file mode 100644
index 0000000000000000000000000000000000000000..5e84b18dda8a03c2910044081b3c5a311af13b83
--- /dev/null
+++ b/homeassistant/components/camera/helper.py
@@ -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")
+
+    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
diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py
new file mode 100644
index 0000000000000000000000000000000000000000..05924855bc4caaede0d3f938023f07fdad935b7f
--- /dev/null
+++ b/homeassistant/components/camera/webrtc.py
@@ -0,0 +1,239 @@
+"""Helper for WebRTC support."""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Awaitable, Callable, Coroutine
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any, Protocol
+
+from mashumaro import field_options
+from mashumaro.config import BaseConfig
+from mashumaro.mixins.dict import DataClassDictMixin
+import voluptuous as vol
+
+from homeassistant.components import websocket_api
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import config_validation as cv
+from homeassistant.util.hass_dict import HassKey
+
+from .const import DATA_COMPONENT, DOMAIN, StreamType
+from .helper import get_camera_from_entity_id
+
+if TYPE_CHECKING:
+    from . import Camera
+
+
+DATA_WEBRTC_PROVIDERS: HassKey[set[CameraWebRTCProvider]] = HassKey(
+    "camera_web_rtc_providers"
+)
+DATA_ICE_SERVERS: HassKey[list[Callable[[], Coroutine[Any, Any, RTCIceServer]]]] = (
+    HassKey("camera_web_rtc_ice_servers")
+)
+
+
+class _RTCBaseModel(DataClassDictMixin):
+    """Base class for RTC models."""
+
+    class Config(BaseConfig):
+        """Mashumaro config."""
+
+        # Serialize to spec conform names and omit default values
+        omit_default = True
+        serialize_by_alias = True
+
+
+@dataclass
+class RTCIceServer(_RTCBaseModel):
+    """RTC Ice Server.
+
+    See https://www.w3.org/TR/webrtc/#rtciceserver-dictionary
+    """
+
+    urls: list[str] | str
+    username: str | None = None
+    credential: str | None = None
+
+
+@dataclass
+class RTCConfiguration(_RTCBaseModel):
+    """RTC Configuration.
+
+    See https://www.w3.org/TR/webrtc/#rtcconfiguration-dictionary
+    """
+
+    ice_servers: list[RTCIceServer] = field(
+        metadata=field_options(alias="iceServers"), default_factory=list
+    )
+
+
+@dataclass(kw_only=True)
+class WebRTCClientConfiguration(_RTCBaseModel):
+    """WebRTC configuration for the client.
+
+    Not part of the spec, but required to configure client.
+    """
+
+    configuration: RTCConfiguration = field(default_factory=RTCConfiguration)
+    data_channel: str | None = field(
+        metadata=field_options(alias="dataChannel"), default=None
+    )
+
+
+class CameraWebRTCProvider(Protocol):
+    """WebRTC provider."""
+
+    async def async_is_supported(self, stream_source: str) -> bool:
+        """Determine if the provider supports the stream source."""
+
+    async def async_handle_web_rtc_offer(
+        self, camera: Camera, offer_sdp: str
+    ) -> str | None:
+        """Handle the WebRTC offer and return an answer."""
+
+
+def async_register_webrtc_provider(
+    hass: HomeAssistant,
+    provider: CameraWebRTCProvider,
+) -> Callable[[], None]:
+    """Register a 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")
+
+    providers: set[CameraWebRTCProvider] = hass.data.setdefault(
+        DATA_WEBRTC_PROVIDERS, set()
+    )
+
+    @callback
+    def remove_provider() -> None:
+        providers.remove(provider)
+        hass.async_create_task(_async_refresh_providers(hass))
+
+    if provider in providers:
+        raise ValueError("Provider already registered")
+
+    providers.add(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)
+    )
+
+
+@websocket_api.websocket_command(
+    {
+        vol.Required("type"): "camera/webrtc/get_client_config",
+        vol.Required("entity_id"): cv.entity_id,
+    }
+)
+@websocket_api.async_response
+async def ws_get_client_config(
+    hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
+) -> None:
+    """Handle get WebRTC client config websocket command."""
+    entity_id = msg["entity_id"]
+    camera = get_camera_from_entity_id(hass, entity_id)
+    if camera.frontend_stream_type != StreamType.WEB_RTC:
+        connection.send_error(
+            msg["id"],
+            "web_rtc_offer_failed",
+            (
+                "Camera does not support WebRTC,"
+                f" frontend_stream_type={camera.frontend_stream_type}"
+            ),
+        )
+        return
+
+    config = (await camera.async_get_webrtc_client_configuration()).to_dict()
+    connection.send_result(
+        msg["id"],
+        config,
+    )
+
+
+async def async_get_supported_providers(
+    hass: HomeAssistant, camera: Camera
+) -> list[CameraWebRTCProvider]:
+    """Return a list of supported providers for the camera."""
+    providers = hass.data.get(DATA_WEBRTC_PROVIDERS)
+    if not providers or not (stream_source := await camera.stream_source()):
+        return []
+
+    return [
+        provider
+        for provider in providers
+        if await provider.async_is_supported(stream_source)
+    ]
+
+
+@callback
+def register_ice_server(
+    hass: HomeAssistant,
+    get_ice_server_fn: Callable[[], Coroutine[Any, Any, RTCIceServer]],
+) -> Callable[[], None]:
+    """Register a ICE server.
+
+    The registering integration is responsible to implement caching if needed.
+    """
+    servers = hass.data.setdefault(DATA_ICE_SERVERS, [])
+
+    def remove() -> None:
+        servers.remove(get_ice_server_fn)
+
+    servers.append(get_ice_server_fn)
+    return remove
+
+
+# The following code is legacy code that was introduced with rtsp_to_webrtc and will be deprecated/removed in the future.
+# Left it so custom integrations can still use it.
+
+_RTSP_PREFIXES = {"rtsp://", "rtsps://", "rtmp://"}
+
+# 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]]
+
+
+class _CameraRtspToWebRTCProvider(CameraWebRTCProvider):
+    def __init__(self, fn: RtspToWebRtcProviderType) -> None:
+        """Initialize the RTSP to WebRTC provider."""
+        self._fn = fn
+
+    async def async_is_supported(self, stream_source: str) -> bool:
+        """Return if this provider is supports the Camera as source."""
+        return any(stream_source.startswith(prefix) for prefix in _RTSP_PREFIXES)
+
+    async def async_handle_web_rtc_offer(
+        self, camera: Camera, offer_sdp: str
+    ) -> str | None:
+        """Handle the WebRTC offer and return an answer."""
+        if not (stream_source := await camera.stream_source()):
+            return None
+
+        return await self._fn(stream_source, offer_sdp, camera.entity_id)
+
+
+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.
+    """
+    provider_instance = _CameraRtspToWebRTCProvider(provider)
+    return async_register_webrtc_provider(hass, provider_instance)
diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..4ff7ee73efc31f79d787adf055263be5f8c4df96
--- /dev/null
+++ b/homeassistant/components/go2rtc/__init__.py
@@ -0,0 +1,91 @@
+"""The go2rtc component."""
+
+from go2rtc_client import Go2RtcClient, WebRTCSdpOffer
+
+from homeassistant.components.camera import Camera
+from homeassistant.components.camera.webrtc import (
+    CameraWebRTCProvider,
+    async_register_webrtc_provider,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_HOST
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import CONF_BINARY
+from .server import Server
+
+_SUPPORTED_STREAMS = (
+    "bubble",
+    "dvrip",
+    "expr",
+    "ffmpeg",
+    "gopro",
+    "homekit",
+    "http",
+    "https",
+    "httpx",
+    "isapi",
+    "ivideon",
+    "kasa",
+    "nest",
+    "onvif",
+    "roborock",
+    "rtmp",
+    "rtmps",
+    "rtmpx",
+    "rtsp",
+    "rtsps",
+    "rtspx",
+    "tapo",
+    "tcp",
+    "webrtc",
+    "webtorrent",
+)
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+    """Set up WebRTC from a config entry."""
+    if binary := entry.data.get(CONF_BINARY):
+        # HA will manage the binary
+        server = Server(binary)
+        entry.async_on_unload(server.stop)
+        server.start()
+
+    client = Go2RtcClient(async_get_clientsession(hass), entry.data[CONF_HOST])
+
+    provider = WebRTCProvider(client)
+    entry.async_on_unload(async_register_webrtc_provider(hass, provider))
+    return True
+
+
+class WebRTCProvider(CameraWebRTCProvider):
+    """WebRTC provider."""
+
+    def __init__(self, client: Go2RtcClient) -> None:
+        """Initialize the WebRTC provider."""
+        self._client = client
+
+    async 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 _SUPPORTED_STREAMS
+
+    async def async_handle_web_rtc_offer(
+        self, camera: Camera, offer_sdp: str
+    ) -> str | None:
+        """Handle the WebRTC offer and return an answer."""
+        streams = await self._client.streams.list()
+        if camera.entity_id not in streams:
+            if not (stream_source := await camera.stream_source()):
+                return None
+            await self._client.streams.add(camera.entity_id, stream_source)
+
+        answer = await self._client.webrtc.forward_whep_sdp_offer(
+            camera.entity_id, WebRTCSdpOffer(offer_sdp)
+        )
+        return answer.sdp
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+    """Unload a config entry."""
+    return True
diff --git a/homeassistant/components/go2rtc/config_flow.py b/homeassistant/components/go2rtc/config_flow.py
new file mode 100644
index 0000000000000000000000000000000000000000..5162850461402ba20899a45f185398c2dae4e3f7
--- /dev/null
+++ b/homeassistant/components/go2rtc/config_flow.py
@@ -0,0 +1,90 @@
+"""Config flow for WebRTC."""
+
+from __future__ import annotations
+
+import shutil
+from typing import Any
+from urllib.parse import urlparse
+
+from go2rtc_client import Go2RtcClient
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_HOST
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import selector
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.util.package import is_docker_env
+
+from .const import CONF_BINARY, DOMAIN
+
+_VALID_URL_SCHEMA = {"http", "https"}
+
+
+async def _validate_url(
+    hass: HomeAssistant,
+    value: str,
+) -> str | None:
+    """Validate the URL and return error or None if it's valid."""
+    if urlparse(value).scheme not in _VALID_URL_SCHEMA:
+        return "invalid_url_schema"
+    try:
+        vol.Schema(vol.Url())(value)
+    except vol.Invalid:
+        return "invalid_url"
+
+    try:
+        client = Go2RtcClient(async_get_clientsession(hass), value)
+        await client.streams.list()
+    except Exception:  # noqa: BLE001
+        return "cannot_connect"
+    return None
+
+
+class Go2RTCConfigFlow(ConfigFlow, domain=DOMAIN):
+    """go2rtc config flow."""
+
+    def _get_binary(self) -> str | None:
+        """Return the binary path if found."""
+        return shutil.which(DOMAIN)
+
+    async def async_step_user(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Init step."""
+        if is_docker_env() and (binary := self._get_binary()):
+            return self.async_create_entry(
+                title=DOMAIN,
+                data={CONF_BINARY: binary, CONF_HOST: "http://localhost:1984/"},
+            )
+
+        return await self.async_step_host()
+
+    async def async_step_host(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Step to use selfhosted go2rtc server."""
+        errors = {}
+        if user_input is not None:
+            if error := await _validate_url(self.hass, user_input[CONF_HOST]):
+                errors[CONF_HOST] = error
+            else:
+                return self.async_create_entry(title=DOMAIN, data=user_input)
+
+        return self.async_show_form(
+            step_id="host",
+            data_schema=self.add_suggested_values_to_schema(
+                data_schema=vol.Schema(
+                    {
+                        vol.Required(CONF_HOST): selector.TextSelector(
+                            selector.TextSelectorConfig(
+                                type=selector.TextSelectorType.URL
+                            )
+                        ),
+                    }
+                ),
+                suggested_values=user_input,
+            ),
+            errors=errors,
+            last_step=True,
+        )
diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py
new file mode 100644
index 0000000000000000000000000000000000000000..af8266e0d723db8bc3e91e39e8f40776ebb43a64
--- /dev/null
+++ b/homeassistant/components/go2rtc/const.py
@@ -0,0 +1,5 @@
+"""Go2rtc constants."""
+
+DOMAIN = "go2rtc"
+
+CONF_BINARY = "binary"
diff --git a/homeassistant/components/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json
new file mode 100644
index 0000000000000000000000000000000000000000..faf6c991ac1719804c29d4d2cc6551c0d611046a
--- /dev/null
+++ b/homeassistant/components/go2rtc/manifest.json
@@ -0,0 +1,11 @@
+{
+  "domain": "go2rtc",
+  "name": "go2rtc",
+  "codeowners": ["@home-assistant/core"],
+  "config_flow": true,
+  "dependencies": ["camera"],
+  "documentation": "https://www.home-assistant.io/integrations/go2rtc",
+  "iot_class": "local_polling",
+  "requirements": ["go2rtc-client==0.0.1b0"],
+  "single_config_entry": true
+}
diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py
new file mode 100644
index 0000000000000000000000000000000000000000..fc9c2b17f60046b50ba81416a054233650ae80e3
--- /dev/null
+++ b/homeassistant/components/go2rtc/server.py
@@ -0,0 +1,56 @@
+"""Go2rtc server."""
+
+from __future__ import annotations
+
+import logging
+import subprocess
+from tempfile import NamedTemporaryFile
+from threading import Thread
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class Server(Thread):
+    """Server thread."""
+
+    def __init__(self, binary: str) -> None:
+        """Initialize the server."""
+        super().__init__(name=DOMAIN, daemon=True)
+        self._binary = binary
+        self._stop_requested = False
+
+    def run(self) -> None:
+        """Run the server."""
+        _LOGGER.debug("Starting go2rtc server")
+        self._stop_requested = False
+        with (
+            NamedTemporaryFile(prefix="go2rtc", suffix=".yaml") as file,
+            subprocess.Popen(
+                [self._binary, "-c", "webrtc.ice_servers=[]", "-c", file.name],
+                stdout=subprocess.PIPE,
+                stderr=subprocess.STDOUT,
+            ) as process,
+        ):
+            while not self._stop_requested and process.poll() is None:
+                assert process.stdout
+                line = process.stdout.readline()
+                if line == b"":
+                    break
+                _LOGGER.debug(line[:-1].decode())
+
+            _LOGGER.debug("Terminating go2rtc server")
+            process.terminate()
+            try:
+                process.wait(timeout=5)
+            except subprocess.TimeoutExpired:
+                _LOGGER.warning("Go2rtc server didn't terminate gracefully.Killing it")
+                process.kill()
+        _LOGGER.debug("Go2rtc server has been stopped")
+
+    def stop(self) -> None:
+        """Stop the server."""
+        self._stop_requested = True
+        if self.is_alive():
+            self.join()
diff --git a/homeassistant/components/go2rtc/strings.json b/homeassistant/components/go2rtc/strings.json
new file mode 100644
index 0000000000000000000000000000000000000000..44e28d712c1b5d02a30794daf0d5593be321c131
--- /dev/null
+++ b/homeassistant/components/go2rtc/strings.json
@@ -0,0 +1,19 @@
+{
+  "config": {
+    "step": {
+      "host": {
+        "data": {
+          "host": "[%key:common::config_flow::data::url%]"
+        },
+        "data_description": {
+          "host": "The URL of your go2rtc instance."
+        }
+      }
+    },
+    "error": {
+      "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+      "invalid_url": "Invalid URL",
+      "invalid_url_schema": "Invalid URL scheme.\nThe URL should start with `http://` or `https://`."
+    }
+  }
+}
diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py
index e87c9ccbbe7c5decdee47f00e61af9fb1b2cb997..e25ff82694f961a087d44765ec9a3a55244b70c2 100644
--- a/homeassistant/components/nest/camera.py
+++ b/homeassistant/components/nest/camera.py
@@ -21,6 +21,7 @@ from google_nest_sdm.device_manager import DeviceManager
 from google_nest_sdm.exceptions import ApiException
 
 from homeassistant.components.camera import Camera, CameraEntityFeature, StreamType
+from homeassistant.components.camera.webrtc import WebRTCClientConfiguration
 from homeassistant.components.stream import CONF_EXTRA_PART_WAIT_TIME
 from homeassistant.config_entries import ConfigEntry
 from homeassistant.core import HomeAssistant
@@ -210,3 +211,7 @@ class NestCamera(Camera):
         except ApiException as err:
             raise HomeAssistantError(f"Nest API error: {err}") from err
         return stream.answer_sdp
+
+    async def _async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration:
+        """Return the WebRTC client configuration adjustable per integration."""
+        return WebRTCClientConfiguration(data_channel="dataSendChannel")
diff --git a/homeassistant/components/rtsp_to_webrtc/__init__.py b/homeassistant/components/rtsp_to_webrtc/__init__.py
index 77bf7ffeb8f3d47e5d31a5d50d2cea52727d6a41..948ba8929fc22fcc0f96ca08256d5ba711833c99 100644
--- a/homeassistant/components/rtsp_to_webrtc/__init__.py
+++ b/homeassistant/components/rtsp_to_webrtc/__init__.py
@@ -12,7 +12,7 @@ the offer/answer SDP protocol, other than as a signal path pass through.
 
 Other integrations may use this integration with these steps:
 - Check if this integration is loaded
-- Call is_suported_stream_source for compatibility
+- Call is_supported_stream_source for compatibility
 - Call async_offer_for_stream_source to get back an answer for a client offer
 """
 
@@ -20,16 +20,15 @@ from __future__ import annotations
 
 import asyncio
 import logging
-from typing import Any
 
 from rtsp_to_webrtc.client import get_adaptive_client
 from rtsp_to_webrtc.exceptions import ClientError, ResponseError
 from rtsp_to_webrtc.interface import WebRTCClientInterface
-import voluptuous as vol
 
-from homeassistant.components import camera, websocket_api
+from homeassistant.components import camera
+from homeassistant.components.camera.webrtc import RTCIceServer, register_ice_server
 from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import HomeAssistant
 from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
 from homeassistant.helpers.aiohttp_client import async_get_clientsession
 
@@ -57,7 +56,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     except (TimeoutError, ClientError) as err:
         raise ConfigEntryNotReady from err
 
-    hass.data[DOMAIN][CONF_STUN_SERVER] = entry.options.get(CONF_STUN_SERVER, "")
+    hass.data[DOMAIN][CONF_STUN_SERVER] = entry.options.get(CONF_STUN_SERVER)
+    if server := entry.options.get(CONF_STUN_SERVER):
+
+        async def get_server() -> RTCIceServer:
+            return RTCIceServer(urls=[server])
+
+        entry.async_on_unload(register_ice_server(hass, get_server))
 
     async def async_offer_for_stream_source(
         stream_source: str,
@@ -85,8 +90,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     )
     entry.async_on_unload(entry.add_update_listener(async_reload_entry))
 
-    websocket_api.async_register_command(hass, ws_get_settings)
-
     return True
 
 
@@ -99,21 +102,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 
 async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
     """Reload config entry when options change."""
-    if hass.data[DOMAIN][CONF_STUN_SERVER] != entry.options.get(CONF_STUN_SERVER, ""):
+    if hass.data[DOMAIN][CONF_STUN_SERVER] != entry.options.get(CONF_STUN_SERVER):
         await hass.config_entries.async_reload(entry.entry_id)
-
-
-@websocket_api.websocket_command(
-    {
-        vol.Required("type"): "rtsp_to_webrtc/get_settings",
-    }
-)
-@callback
-def ws_get_settings(
-    hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
-) -> None:
-    """Handle the websocket command."""
-    connection.send_result(
-        msg["id"],
-        {CONF_STUN_SERVER: hass.data.get(DOMAIN, {}).get(CONF_STUN_SERVER, "")},
-    )
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index 40ddcbd86c0e67ec12e665142c13e2a981f66390..10e27ff2c975a106590b7adb9df505a25bb0ab2b 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -221,6 +221,7 @@ FLOWS = {
         "gios",
         "github",
         "glances",
+        "go2rtc",
         "goalzero",
         "gogogate2",
         "goodwe",
diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json
index 2972aabbbfccf90dbb8eca3378c4fce91f6ae77a..7b1cb0450413cecbbf778692eb8255908764df3b 100644
--- a/homeassistant/generated/integrations.json
+++ b/homeassistant/generated/integrations.json
@@ -2247,6 +2247,13 @@
         }
       }
     },
+    "go2rtc": {
+      "name": "go2rtc",
+      "integration_type": "hub",
+      "config_flow": true,
+      "iot_class": "local_polling",
+      "single_config_entry": true
+    },
     "goalzero": {
       "name": "Goal Zero Yeti",
       "integration_type": "device",
diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt
index 2db64bfd61902d17ddb121eebf56f843da18ba39..786af866c810070a56abefcf233286cc15b48427 100644
--- a/homeassistant/package_constraints.txt
+++ b/homeassistant/package_constraints.txt
@@ -38,6 +38,7 @@ httpx==0.27.2
 ifaddr==0.2.0
 Jinja2==3.1.4
 lru-dict==1.3.0
+mashumaro==3.13.1
 mutagen==1.47.0
 orjson==3.10.7
 packaging>=23.1
@@ -121,9 +122,6 @@ backoff>=2.0
 # v2 has breaking changes (#99218).
 pydantic==1.10.18
 
-# Required for Python 3.12.4 compatibility (#119223).
-mashumaro>=3.13.1
-
 # Breaks asyncio
 # https://github.com/pubnub/python/issues/130
 pubnub!=6.4.0
diff --git a/pyproject.toml b/pyproject.toml
index 56ca531257105e0741a4c0af42880e2e1e947bff..2cd8ff7502d93bd28c848573404780cc7242d997 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -50,6 +50,7 @@ dependencies    = [
     "ifaddr==0.2.0",
     "Jinja2==3.1.4",
     "lru-dict==1.3.0",
+    "mashumaro==3.13.1",
     "PyJWT==2.9.0",
     # PyJWT has loose dependency. We want the latest one.
     "cryptography==43.0.1",
diff --git a/requirements.txt b/requirements.txt
index 500af7a67939830ee326fbeba1a8d2db565401b2..178539f991c74c9227c8c56adf912265fb4371e0 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -24,6 +24,7 @@ home-assistant-bluetooth==1.12.2
 ifaddr==0.2.0
 Jinja2==3.1.4
 lru-dict==1.3.0
+mashumaro==3.13.1
 PyJWT==2.9.0
 cryptography==43.0.1
 Pillow==10.4.0
diff --git a/requirements_all.txt b/requirements_all.txt
index 3e24aa507f1b2d66d1a2a38a0779dd91a0fd1618..fd823c63ff4b8a296d554d15071561bb6614940c 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -981,6 +981,9 @@ gitterpy==0.1.7
 # homeassistant.components.glances
 glances-api==0.8.0
 
+# homeassistant.components.go2rtc
+go2rtc-client==0.0.1b0
+
 # homeassistant.components.goalzero
 goalzero==0.2.2
 
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 4e5c736c9d71dec839cf536ba2b7d09b8b3abd74..42f647f07d2b9e2bcb6beb28478f4e314f23f55d 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -831,6 +831,9 @@ gios==4.0.0
 # homeassistant.components.glances
 glances-api==0.8.0
 
+# homeassistant.components.go2rtc
+go2rtc-client==0.0.1b0
+
 # homeassistant.components.goalzero
 goalzero==0.2.2
 
diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py
index 4641d4ac12ac59a76fb34f940ba95d81cbb84e6e..7787578902cac704ada7d84020bda2319fe3b69a 100755
--- a/script/gen_requirements_all.py
+++ b/script/gen_requirements_all.py
@@ -140,9 +140,6 @@ backoff>=2.0
 # v2 has breaking changes (#99218).
 pydantic==1.10.18
 
-# Required for Python 3.12.4 compatibility (#119223).
-mashumaro>=3.13.1
-
 # Breaks asyncio
 # https://github.com/pubnub/python/issues/130
 pubnub!=6.4.0
diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py
index d12a7e5f78e86656825faecde4b8385c6ddcdaa6..213f21a7a3e33475ebdf153c958e9326abad33e5 100644
--- a/script/hassfest/docker.py
+++ b/script/hassfest/docker.py
@@ -57,6 +57,21 @@ 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/v{go2rtc}/go2rtc_linux_${{go2rtc_suffix}} --output /bin/go2rtc \
+    && chmod +x /bin/go2rtc \
+    # Verify go2rtc can be executed
+    && go2rtc --version
+
 WORKDIR /config
 """
 
@@ -96,6 +111,8 @@ LABEL "com.github.actions.icon"="terminal"
 LABEL "com.github.actions.color"="gray-dark"
 """
 
+_GO2RTC_VERSION = "1.9.4"
+
 
 def _get_package_versions(file: Path, packages: set[str]) -> dict[str, str]:
     package_versions: dict[str, str] = {}
@@ -176,7 +193,11 @@ def _generate_files(config: Config) -> list[File]:
 
     return [
         File(
-            DOCKERFILE_TEMPLATE.format(timeout=timeout, **package_versions),
+            DOCKERFILE_TEMPLATE.format(
+                timeout=timeout,
+                **package_versions,
+                go2rtc=_GO2RTC_VERSION,
+            ),
             config.root / "Dockerfile",
         ),
         _generate_hassfest_dockerimage(config, timeout, package_versions),
diff --git a/tests/components/axis/test_camera.py b/tests/components/axis/test_camera.py
index 91e24a8c0c0f239bfca272e1640fd2e059dc2ebb..6cc4bbd7c2f75561e03694aab66471aba93d1f91 100644
--- a/tests/components/axis/test_camera.py
+++ b/tests/components/axis/test_camera.py
@@ -59,7 +59,7 @@ async def test_camera(
     await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
 
     entity_id = f"{CAMERA_DOMAIN}.{NAME}"
-    camera_entity = camera._get_camera_from_entity_id(hass, entity_id)
+    camera_entity = camera.helper.get_camera_from_entity_id(hass, entity_id)
     assert camera_entity.image_source == "http://1.2.3.4:80/axis-cgi/jpg/image.cgi"
     assert (
         camera_entity.mjpeg_source == "http://1.2.3.4:80/axis-cgi/mjpg/video.cgi"
diff --git a/tests/components/camera/common.py b/tests/components/camera/common.py
index 9cacf85d9077ae771025743ce78a053ff97f1ace..f7dcf46db01ac1c57f2a649fdf4b674844a8db86 100644
--- a/tests/components/camera/common.py
+++ b/tests/components/camera/common.py
@@ -8,6 +8,7 @@ from unittest.mock import Mock
 
 EMPTY_8_6_JPEG = b"empty_8_6"
 WEBRTC_ANSWER = "a=sendonly"
+STREAM_SOURCE = "rtsp://127.0.0.1/stream"
 
 
 def mock_turbo_jpeg(
diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py
index ea3d65f4864790f569060e05a6d27425e69025e2..5eda2f1eb55656730fbe76c8ff04f17b61692f11 100644
--- a/tests/components/camera/conftest.py
+++ b/tests/components/camera/conftest.py
@@ -1,7 +1,7 @@
 """Test helpers for camera."""
 
 from collections.abc import AsyncGenerator, Generator
-from unittest.mock import PropertyMock, patch
+from unittest.mock import AsyncMock, PropertyMock, patch
 
 import pytest
 
@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
 from homeassistant.helpers.device_registry import DeviceInfo
 from homeassistant.setup import async_setup_component
 
-from .common import WEBRTC_ANSWER
+from .common import STREAM_SOURCE, WEBRTC_ANSWER
 
 
 @pytest.fixture(autouse=True)
@@ -111,3 +111,19 @@ def mock_camera_with_no_name_fixture(mock_camera_with_device: None) -> Generator
         new_callable=PropertyMock(return_value=None),
     ):
         yield
+
+
+@pytest.fixture(name="mock_stream")
+async def mock_stream_fixture(hass: HomeAssistant) -> None:
+    """Initialize a demo camera platform with streaming."""
+    assert await async_setup_component(hass, "stream", {"stream": {}})
+
+
+@pytest.fixture(name="mock_stream_source")
+def mock_stream_source_fixture() -> Generator[AsyncMock]:
+    """Fixture to create an RTSP stream source."""
+    with patch(
+        "homeassistant.components.camera.Camera.stream_source",
+        return_value=STREAM_SOURCE,
+    ) as mock_stream_source:
+        yield mock_stream_source
diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py
index fd3ee8df22e1232ae51e3cf882c707b91ff22465..2b90d6213295317857ab4376b6876049d844175d 100644
--- a/tests/components/camera/test_init.py
+++ b/tests/components/camera/test_init.py
@@ -27,7 +27,7 @@ from homeassistant.helpers import entity_registry as er
 from homeassistant.setup import async_setup_component
 from homeassistant.util import dt as dt_util
 
-from .common import EMPTY_8_6_JPEG, WEBRTC_ANSWER, mock_turbo_jpeg
+from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, WEBRTC_ANSWER, mock_turbo_jpeg
 
 from tests.common import (
     async_fire_time_changed,
@@ -36,19 +36,10 @@ from tests.common import (
 )
 from tests.typing import ClientSessionGenerator, WebSocketGenerator
 
-STREAM_SOURCE = "rtsp://127.0.0.1/stream"
 HLS_STREAM_SOURCE = "http://127.0.0.1/example.m3u"
 WEBRTC_OFFER = "v=0\r\n"
 
 
-@pytest.fixture(name="mock_stream")
-def mock_stream_fixture(hass: HomeAssistant) -> None:
-    """Initialize a demo camera platform with streaming."""
-    assert hass.loop.run_until_complete(
-        async_setup_component(hass, "stream", {"stream": {}})
-    )
-
-
 @pytest.fixture(name="image_mock_url")
 async def image_mock_url_fixture(hass: HomeAssistant) -> None:
     """Fixture for get_image tests."""
@@ -58,16 +49,6 @@ async def image_mock_url_fixture(hass: HomeAssistant) -> None:
     await hass.async_block_till_done()
 
 
-@pytest.fixture(name="mock_stream_source")
-def mock_stream_source_fixture() -> Generator[AsyncMock]:
-    """Fixture to create an RTSP stream source."""
-    with patch(
-        "homeassistant.components.camera.Camera.stream_source",
-        return_value=STREAM_SOURCE,
-    ) as mock_stream_source:
-        yield mock_stream_source
-
-
 @pytest.fixture(name="mock_hls_stream_source")
 async def mock_hls_stream_source_fixture() -> Generator[AsyncMock]:
     """Fixture to create an HLS stream source."""
diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py
new file mode 100644
index 0000000000000000000000000000000000000000..d304c7e5fb0a113822067ac674d8b85d85c925a1
--- /dev/null
+++ b/tests/components/camera/test_webrtc.py
@@ -0,0 +1,236 @@
+"""Test camera WebRTC."""
+
+import pytest
+
+from homeassistant.components.camera import Camera
+from homeassistant.components.camera.const import StreamType
+from homeassistant.components.camera.helper import get_camera_from_entity_id
+from homeassistant.components.camera.webrtc import (
+    DATA_ICE_SERVERS,
+    CameraWebRTCProvider,
+    RTCIceServer,
+    async_register_webrtc_provider,
+    register_ice_server,
+)
+from homeassistant.components.websocket_api import TYPE_RESULT
+from homeassistant.core import HomeAssistant
+from homeassistant.setup import async_setup_component
+
+from tests.typing import WebSocketGenerator
+
+
+@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source")
+async def test_async_register_webrtc_provider(
+    hass: HomeAssistant,
+) -> None:
+    """Test registering a WebRTC provider."""
+    await async_setup_component(hass, "camera", {})
+
+    camera = get_camera_from_entity_id(hass, "camera.demo_camera")
+    assert camera.frontend_stream_type is StreamType.HLS
+
+    stream_supported = True
+
+    class TestProvider(CameraWebRTCProvider):
+        """Test provider."""
+
+        async def async_is_supported(self, stream_source: str) -> bool:
+            """Determine if the provider supports the stream source."""
+            nonlocal stream_supported
+            return stream_supported
+
+        async def async_handle_web_rtc_offer(
+            self, camera: Camera, offer_sdp: str
+        ) -> str | None:
+            """Handle the WebRTC offer and return an answer."""
+            return "answer"
+
+    unregister = async_register_webrtc_provider(hass, TestProvider())
+    await hass.async_block_till_done()
+
+    assert camera.frontend_stream_type is StreamType.WEB_RTC
+
+    # Mark stream as unsupported
+    stream_supported = False
+    # Manually refresh the provider
+    await camera.async_refresh_providers()
+
+    assert camera.frontend_stream_type is StreamType.HLS
+
+    # Mark stream as unsupported
+    stream_supported = True
+    # Manually refresh the provider
+    await camera.async_refresh_providers()
+    assert camera.frontend_stream_type is StreamType.WEB_RTC
+
+    unregister()
+    await hass.async_block_till_done()
+
+    assert camera.frontend_stream_type is StreamType.HLS
+
+
+@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source")
+async def test_async_register_webrtc_provider_twice(
+    hass: HomeAssistant,
+) -> None:
+    """Test registering a WebRTC provider twice should raise."""
+    await async_setup_component(hass, "camera", {})
+
+    class TestProvider(CameraWebRTCProvider):
+        """Test provider."""
+
+        async def async_is_supported(self, stream_source: str) -> bool:
+            """Determine if the provider supports the stream source."""
+            return True
+
+        async def async_handle_web_rtc_offer(
+            self, camera: Camera, offer_sdp: str
+        ) -> str | None:
+            """Handle the WebRTC offer and return an answer."""
+            return "answer"
+
+    provider = TestProvider()
+    async_register_webrtc_provider(hass, provider)
+    await hass.async_block_till_done()
+
+    with pytest.raises(ValueError, match="Provider already registered"):
+        async_register_webrtc_provider(hass, provider)
+
+
+async def test_async_register_webrtc_provider_camera_not_loaded(
+    hass: HomeAssistant,
+) -> None:
+    """Test registering a WebRTC provider when camera is not loaded."""
+
+    class TestProvider(CameraWebRTCProvider):
+        """Test provider."""
+
+        async def async_is_supported(self, stream_source: str) -> bool:
+            """Determine if the provider supports the stream source."""
+            return True
+
+        async def async_handle_web_rtc_offer(
+            self, camera: Camera, offer_sdp: str
+        ) -> str | None:
+            """Handle the WebRTC offer and return an answer."""
+            return "answer"
+
+    with pytest.raises(ValueError, match="Unexpected state, camera not loaded"):
+        async_register_webrtc_provider(hass, TestProvider())
+
+
+@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source")
+async def test_async_register_ice_server(
+    hass: HomeAssistant,
+) -> None:
+    """Test registering an ICE server."""
+    await async_setup_component(hass, "camera", {})
+
+    # Clear any existing ICE servers
+    hass.data[DATA_ICE_SERVERS].clear()
+
+    called = 0
+
+    async def get_ice_server() -> RTCIceServer:
+        nonlocal called
+        called += 1
+        return RTCIceServer(urls="stun:example.com")
+
+    unregister = register_ice_server(hass, get_ice_server)
+    assert not called
+
+    camera = get_camera_from_entity_id(hass, "camera.demo_camera")
+    config = await camera.async_get_webrtc_client_configuration()
+
+    assert config.configuration.ice_servers == [RTCIceServer(urls="stun:example.com")]
+    assert called == 1
+
+    # register another ICE server
+    called_2 = 0
+
+    async def get_ice_server_2() -> RTCIceServer:
+        nonlocal called_2
+        called_2 += 1
+        return RTCIceServer(
+            urls=["stun:example2.com", "turn:example2.com"],
+            username="user",
+            credential="pass",
+        )
+
+    unregister_2 = register_ice_server(hass, get_ice_server_2)
+
+    config = await camera.async_get_webrtc_client_configuration()
+    assert config.configuration.ice_servers == [
+        RTCIceServer(urls="stun:example.com"),
+        RTCIceServer(
+            urls=["stun:example2.com", "turn:example2.com"],
+            username="user",
+            credential="pass",
+        ),
+    ]
+    assert called == 2
+    assert called_2 == 1
+
+    # unregister the first ICE server
+
+    unregister()
+
+    config = await camera.async_get_webrtc_client_configuration()
+    assert config.configuration.ice_servers == [
+        RTCIceServer(
+            urls=["stun:example2.com", "turn:example2.com"],
+            username="user",
+            credential="pass",
+        ),
+    ]
+    assert called == 2
+    assert called_2 == 2
+
+    # unregister the second ICE server
+    unregister_2()
+
+    config = await camera.async_get_webrtc_client_configuration()
+    assert config.configuration.ice_servers == []
+
+
+@pytest.mark.usefixtures("mock_camera_web_rtc")
+async def test_ws_get_client_config(
+    hass: HomeAssistant, hass_ws_client: WebSocketGenerator
+) -> None:
+    """Test get WebRTC client config."""
+    await async_setup_component(hass, "camera", {})
+
+    client = await hass_ws_client(hass)
+    await client.send_json_auto_id(
+        {"type": "camera/webrtc/get_client_config", "entity_id": "camera.demo_camera"}
+    )
+    msg = await client.receive_json()
+
+    # Assert WebSocket response
+    assert msg["type"] == TYPE_RESULT
+    assert msg["success"]
+    assert msg["result"] == {
+        "configuration": {"iceServers": [{"urls": "stun:stun.l.google.com:19302"}]}
+    }
+
+
+@pytest.mark.usefixtures("mock_camera_hls")
+async def test_ws_get_client_config_no_rtc_camera(
+    hass: HomeAssistant, hass_ws_client: WebSocketGenerator
+) -> None:
+    """Test get WebRTC client config."""
+    await async_setup_component(hass, "camera", {})
+
+    client = await hass_ws_client(hass)
+    await client.send_json_auto_id(
+        {"type": "camera/webrtc/get_client_config", "entity_id": "camera.demo_camera"}
+    )
+    msg = await client.receive_json()
+
+    # Assert WebSocket response
+    assert msg["type"] == TYPE_RESULT
+    assert not msg["success"]
+    assert msg["error"] == {
+        "code": "web_rtc_offer_failed",
+        "message": "Camera does not support WebRTC, frontend_stream_type=hls",
+    }
diff --git a/tests/components/go2rtc/__init__.py b/tests/components/go2rtc/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..20cbd67d571e4bae34e67a5423f5836cdb13cc21
--- /dev/null
+++ b/tests/components/go2rtc/__init__.py
@@ -0,0 +1,13 @@
+"""Go2rtc tests."""
+
+from homeassistant.core import HomeAssistant
+
+from tests.common import MockConfigEntry
+
+
+async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
+    """Fixture for setting up the component."""
+    config_entry.add_to_hass(hass)
+
+    await hass.config_entries.async_setup(config_entry.entry_id)
+    await hass.async_block_till_done()
diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py
new file mode 100644
index 0000000000000000000000000000000000000000..02c1b3b908c2550425195c932513e05d2cd6f48b
--- /dev/null
+++ b/tests/components/go2rtc/conftest.py
@@ -0,0 +1,57 @@
+"""Go2rtc test configuration."""
+
+from collections.abc import Generator
+from unittest.mock import AsyncMock, Mock, patch
+
+from go2rtc_client.client import _StreamClient, _WebRTCClient
+import pytest
+
+from homeassistant.components.go2rtc.const import CONF_BINARY, DOMAIN
+from homeassistant.const import CONF_HOST
+
+from tests.common import MockConfigEntry
+
+
+@pytest.fixture
+def mock_setup_entry() -> Generator[AsyncMock]:
+    """Override async_setup_entry."""
+    with patch(
+        "homeassistant.components.go2rtc.async_setup_entry",
+        return_value=True,
+    ) as mock_setup_entry:
+        yield mock_setup_entry
+
+
+@pytest.fixture
+def mock_client() -> Generator[AsyncMock]:
+    """Mock a go2rtc client."""
+    with (
+        patch(
+            "homeassistant.components.go2rtc.Go2RtcClient",
+        ) as mock_client,
+        patch(
+            "homeassistant.components.go2rtc.config_flow.Go2RtcClient",
+            new=mock_client,
+        ),
+    ):
+        client = mock_client.return_value
+        client.streams = Mock(spec_set=_StreamClient)
+        client.webrtc = Mock(spec_set=_WebRTCClient)
+        yield client
+
+
+@pytest.fixture
+def mock_server() -> Generator[Mock]:
+    """Mock a go2rtc server."""
+    with patch("homeassistant.components.go2rtc.Server", autoSpec=True) as mock_server:
+        yield mock_server
+
+
+@pytest.fixture
+def mock_config_entry() -> MockConfigEntry:
+    """Mock a config entry."""
+    return MockConfigEntry(
+        domain=DOMAIN,
+        title=DOMAIN,
+        data={CONF_HOST: "http://localhost:1984/", CONF_BINARY: "/usr/bin/go2rtc"},
+    )
diff --git a/tests/components/go2rtc/test_config_flow.py b/tests/components/go2rtc/test_config_flow.py
new file mode 100644
index 0000000000000000000000000000000000000000..25c993e7d3149a1b0382812f273b0be4f19264a0
--- /dev/null
+++ b/tests/components/go2rtc/test_config_flow.py
@@ -0,0 +1,156 @@
+"""Tests for the Go2rtc config flow."""
+
+from unittest.mock import Mock, patch
+
+import pytest
+
+from homeassistant.components.go2rtc.const import CONF_BINARY, DOMAIN
+from homeassistant.config_entries import SOURCE_USER
+from homeassistant.const import CONF_HOST
+from homeassistant.core import HomeAssistant
+from homeassistant.data_entry_flow import FlowResultType
+
+from tests.common import MockConfigEntry
+
+
+@pytest.mark.usefixtures("mock_client", "mock_setup_entry")
+async def test_single_instance_allowed(
+    hass: HomeAssistant,
+    mock_config_entry: MockConfigEntry,
+) -> None:
+    """Test that flow will abort if already configured."""
+    mock_config_entry.add_to_hass(hass)
+
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN,
+        context={"source": SOURCE_USER},
+    )
+    assert result["type"] is FlowResultType.ABORT
+    assert result["reason"] == "single_instance_allowed"
+
+
+@pytest.mark.usefixtures("mock_setup_entry")
+async def test_docker_with_binary(
+    hass: HomeAssistant,
+) -> None:
+    """Test config flow, where HA is running in docker with a go2rtc binary available."""
+    binary = "/usr/bin/go2rtc"
+    with (
+        patch(
+            "homeassistant.components.go2rtc.config_flow.is_docker_env",
+            return_value=True,
+        ),
+        patch(
+            "homeassistant.components.go2rtc.config_flow.shutil.which",
+            return_value=binary,
+        ),
+    ):
+        result = await hass.config_entries.flow.async_init(
+            DOMAIN,
+            context={"source": SOURCE_USER},
+        )
+        assert result["type"] is FlowResultType.CREATE_ENTRY
+        assert result["title"] == "go2rtc"
+        assert result["data"] == {
+            CONF_BINARY: binary,
+            CONF_HOST: "http://localhost:1984/",
+        }
+
+
+@pytest.mark.usefixtures("mock_setup_entry", "mock_client")
+@pytest.mark.parametrize(
+    ("is_docker_env", "shutil_which"),
+    [
+        (True, None),
+        (False, None),
+        (False, "/usr/bin/go2rtc"),
+    ],
+)
+async def test_config_flow_host(
+    hass: HomeAssistant,
+    is_docker_env: bool,
+    shutil_which: str | None,
+) -> None:
+    """Test config flow with host input."""
+    with (
+        patch(
+            "homeassistant.components.go2rtc.config_flow.is_docker_env",
+            return_value=is_docker_env,
+        ),
+        patch(
+            "homeassistant.components.go2rtc.config_flow.shutil.which",
+            return_value=shutil_which,
+        ),
+    ):
+        result = await hass.config_entries.flow.async_init(
+            DOMAIN,
+            context={"source": SOURCE_USER},
+        )
+        assert result["type"] is FlowResultType.FORM
+        assert result["step_id"] == "host"
+        host = "http://go2rtc.local:1984/"
+
+        result = await hass.config_entries.flow.async_configure(
+            result["flow_id"],
+            {CONF_HOST: host},
+        )
+
+        assert result["type"] is FlowResultType.CREATE_ENTRY
+        assert result["title"] == "go2rtc"
+        assert result["data"] == {
+            CONF_HOST: host,
+        }
+
+
+@pytest.mark.usefixtures("mock_setup_entry")
+async def test_flow_errors(
+    hass: HomeAssistant,
+    mock_client: Mock,
+) -> None:
+    """Test flow errors."""
+    with (
+        patch(
+            "homeassistant.components.go2rtc.config_flow.is_docker_env",
+            return_value=False,
+        ),
+    ):
+        result = await hass.config_entries.flow.async_init(
+            DOMAIN,
+            context={"source": SOURCE_USER},
+        )
+        assert result["type"] is FlowResultType.FORM
+        assert result["step_id"] == "host"
+
+        result = await hass.config_entries.flow.async_configure(
+            result["flow_id"],
+            {CONF_HOST: "go2rtc.local:1984/"},
+        )
+        assert result["type"] is FlowResultType.FORM
+        assert result["errors"] == {"host": "invalid_url_schema"}
+
+        result = await hass.config_entries.flow.async_configure(
+            result["flow_id"],
+            {CONF_HOST: "http://"},
+        )
+        assert result["type"] is FlowResultType.FORM
+        assert result["errors"] == {"host": "invalid_url"}
+
+        host = "http://go2rtc.local:1984/"
+        mock_client.streams.list.side_effect = Exception
+        result = await hass.config_entries.flow.async_configure(
+            result["flow_id"],
+            {CONF_HOST: host},
+        )
+        assert result["type"] is FlowResultType.FORM
+        assert result["errors"] == {"host": "cannot_connect"}
+
+        mock_client.streams.list.side_effect = None
+        result = await hass.config_entries.flow.async_configure(
+            result["flow_id"],
+            {CONF_HOST: host},
+        )
+        assert result["type"] is FlowResultType.CREATE_ENTRY
+        assert result["title"] == "go2rtc"
+        assert result["data"] == {
+            CONF_HOST: host,
+        }
diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py
new file mode 100644
index 0000000000000000000000000000000000000000..afd336dc2b83025fd7e38a021a09327a12200cd5
--- /dev/null
+++ b/tests/components/go2rtc/test_init.py
@@ -0,0 +1,219 @@
+"""The tests for the go2rtc component."""
+
+from collections.abc import Callable
+from unittest.mock import AsyncMock, Mock
+
+from go2rtc_client import Stream, WebRTCSdpAnswer, WebRTCSdpOffer
+from go2rtc_client.models import Producer
+import pytest
+
+from homeassistant.components.camera import (
+    DOMAIN as CAMERA_DOMAIN,
+    Camera,
+    CameraEntityFeature,
+)
+from homeassistant.components.camera.const import StreamType
+from homeassistant.components.camera.helper import get_camera_from_entity_id
+from homeassistant.components.go2rtc import WebRTCProvider
+from homeassistant.components.go2rtc.const import DOMAIN
+from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow
+from homeassistant.const import CONF_HOST
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+
+from . import setup_integration
+
+from tests.common import (
+    MockConfigEntry,
+    MockModule,
+    mock_config_flow,
+    mock_integration,
+    mock_platform,
+    setup_test_component_platform,
+)
+
+TEST_DOMAIN = "test"
+
+# The go2rtc provider does not inspect the details of the offer and answer,
+# and is only a pass through.
+OFFER_SDP = "v=0\r\no=carol 28908764872 28908764872 IN IP4 100.3.6.6\r\n..."
+ANSWER_SDP = "v=0\r\no=bob 2890844730 2890844730 IN IP4 host.example.com\r\n..."
+
+
+class MockCamera(Camera):
+    """Mock Camera Entity."""
+
+    _attr_name = "Test"
+    _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM
+
+    def __init__(self) -> None:
+        """Initialize the mock entity."""
+        super().__init__()
+        self._stream_source: str | None = "rtsp://stream"
+
+    def set_stream_source(self, stream_source: str | None) -> None:
+        """Set the stream source."""
+        self._stream_source = stream_source
+
+    async def stream_source(self) -> str | None:
+        """Return the source of the stream.
+
+        This is used by cameras with CameraEntityFeature.STREAM
+        and StreamType.HLS.
+        """
+        return self._stream_source
+
+
+@pytest.fixture
+def integration_entity() -> MockCamera:
+    """Mock Camera Entity."""
+    return MockCamera()
+
+
+@pytest.fixture
+def integration_config_entry(hass: HomeAssistant) -> ConfigEntry:
+    """Test mock config entry."""
+    entry = MockConfigEntry(domain=TEST_DOMAIN)
+    entry.add_to_hass(hass)
+    return entry
+
+
+@pytest.fixture
+async def init_test_integration(
+    hass: HomeAssistant,
+    integration_config_entry: ConfigEntry,
+    integration_entity: MockCamera,
+) -> None:
+    """Initialize components."""
+
+    async def async_setup_entry_init(
+        hass: HomeAssistant, config_entry: ConfigEntry
+    ) -> bool:
+        """Set up test config entry."""
+        await hass.config_entries.async_forward_entry_setups(
+            config_entry, [CAMERA_DOMAIN]
+        )
+        return True
+
+    async def async_unload_entry_init(
+        hass: HomeAssistant, config_entry: ConfigEntry
+    ) -> bool:
+        """Unload test config entry."""
+        await hass.config_entries.async_forward_entry_unload(
+            config_entry, CAMERA_DOMAIN
+        )
+        return True
+
+    mock_integration(
+        hass,
+        MockModule(
+            TEST_DOMAIN,
+            async_setup_entry=async_setup_entry_init,
+            async_unload_entry=async_unload_entry_init,
+        ),
+    )
+    setup_test_component_platform(
+        hass, CAMERA_DOMAIN, [integration_entity], from_config_entry=True
+    )
+    mock_platform(hass, f"{TEST_DOMAIN}.config_flow", Mock())
+
+    with mock_config_flow(TEST_DOMAIN, ConfigFlow):
+        assert await hass.config_entries.async_setup(integration_config_entry.entry_id)
+        await hass.async_block_till_done()
+
+    return integration_config_entry
+
+
+@pytest.mark.usefixtures("init_test_integration")
+async def _test_setup(
+    hass: HomeAssistant,
+    mock_client: AsyncMock,
+    mock_config_entry: MockConfigEntry,
+    after_setup_fn: Callable[[], None],
+) -> None:
+    """Test the go2rtc config entry."""
+    entity_id = "camera.test"
+    camera = get_camera_from_entity_id(hass, entity_id)
+    assert camera.frontend_stream_type == StreamType.HLS
+
+    await setup_integration(hass, mock_config_entry)
+    after_setup_fn()
+
+    mock_client.webrtc.forward_whep_sdp_offer.return_value = WebRTCSdpAnswer(ANSWER_SDP)
+
+    answer = await camera.async_handle_web_rtc_offer(OFFER_SDP)
+    assert answer == ANSWER_SDP
+
+    mock_client.webrtc.forward_whep_sdp_offer.assert_called_once_with(
+        entity_id, WebRTCSdpOffer(OFFER_SDP)
+    )
+    mock_client.streams.add.assert_called_once_with(entity_id, "rtsp://stream")
+
+    # If the stream is already added, the stream should not be added again.
+    mock_client.streams.add.reset_mock()
+    mock_client.streams.list.return_value = {
+        entity_id: Stream([Producer("rtsp://stream")])
+    }
+
+    answer = await camera.async_handle_web_rtc_offer(OFFER_SDP)
+    assert answer == ANSWER_SDP
+    mock_client.streams.add.assert_not_called()
+    assert mock_client.webrtc.forward_whep_sdp_offer.call_count == 2
+    assert isinstance(camera._webrtc_providers[0], WebRTCProvider)
+
+    # Set stream source to None and provider should be skipped
+    mock_client.streams.list.return_value = {}
+    camera.set_stream_source(None)
+    with pytest.raises(
+        HomeAssistantError,
+        match="WebRTC offer was not accepted by the supported providers",
+    ):
+        await camera.async_handle_web_rtc_offer(OFFER_SDP)
+
+    # Remove go2rtc config entry
+    assert mock_config_entry.state is ConfigEntryState.LOADED
+    await hass.config_entries.async_remove(mock_config_entry.entry_id)
+    await hass.async_block_till_done()
+    assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
+
+    assert camera._webrtc_providers == []
+    assert camera.frontend_stream_type == StreamType.HLS
+
+
+@pytest.mark.usefixtures("init_test_integration")
+async def test_setup_go_binary(
+    hass: HomeAssistant,
+    mock_client: AsyncMock,
+    mock_server: Mock,
+    mock_config_entry: MockConfigEntry,
+) -> None:
+    """Test the go2rtc config entry with binary."""
+
+    def after_setup() -> None:
+        mock_server.assert_called_once_with("/usr/bin/go2rtc")
+        mock_server.return_value.start.assert_called_once()
+
+    await _test_setup(hass, mock_client, mock_config_entry, after_setup)
+
+    mock_server.return_value.stop.assert_called_once()
+
+
+@pytest.mark.usefixtures("init_test_integration")
+async def test_setup_go(
+    hass: HomeAssistant,
+    mock_client: AsyncMock,
+    mock_server: Mock,
+) -> None:
+    """Test the go2rtc config entry without binary."""
+    config_entry = MockConfigEntry(
+        domain=DOMAIN,
+        title=DOMAIN,
+        data={CONF_HOST: "http://localhost:1984/"},
+    )
+
+    def after_setup() -> None:
+        mock_server.assert_not_called()
+
+    await _test_setup(hass, mock_client, config_entry, after_setup)
+
+    mock_server.assert_not_called()
diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py
new file mode 100644
index 0000000000000000000000000000000000000000..1617ea55015392d049dde6b68e493eb21303f912
--- /dev/null
+++ b/tests/components/go2rtc/test_server.py
@@ -0,0 +1,91 @@
+"""Tests for the go2rtc server."""
+
+import asyncio
+from collections.abc import Generator
+import subprocess
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from homeassistant.components.go2rtc.server import Server
+
+TEST_BINARY = "/bin/go2rtc"
+
+
+@pytest.fixture
+def server() -> Server:
+    """Fixture to initialize the Server."""
+    return Server(binary=TEST_BINARY)
+
+
+@pytest.fixture
+def mock_tempfile() -> Generator[MagicMock]:
+    """Fixture to mock NamedTemporaryFile."""
+    with patch(
+        "homeassistant.components.go2rtc.server.NamedTemporaryFile"
+    ) as mock_tempfile:
+        mock_tempfile.return_value.__enter__.return_value.name = "test.yaml"
+        yield mock_tempfile
+
+
+@pytest.fixture
+def mock_popen() -> Generator[MagicMock]:
+    """Fixture to mock subprocess.Popen."""
+    with patch("homeassistant.components.go2rtc.server.subprocess.Popen") as mock_popen:
+        yield mock_popen
+
+
+@pytest.mark.usefixtures("mock_tempfile")
+async def test_server_run_success(mock_popen: MagicMock, server: Server) -> None:
+    """Test that the server runs successfully."""
+    mock_process = MagicMock()
+    mock_process.poll.return_value = None  # Simulate process running
+    # Simulate process output
+    mock_process.stdout.readline.side_effect = [
+        b"log line 1\n",
+        b"log line 2\n",
+        b"",
+    ]
+    mock_popen.return_value.__enter__.return_value = mock_process
+
+    server.start()
+    await asyncio.sleep(0)
+
+    # Check that Popen was called with the right arguments
+    mock_popen.assert_called_once_with(
+        [TEST_BINARY, "-c", "webrtc.ice_servers=[]", "-c", "test.yaml"],
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+    )
+
+    # Check that server read the log lines
+    assert mock_process.stdout.readline.call_count == 3
+
+    server.stop()
+    mock_process.terminate.assert_called_once()
+    assert not server.is_alive()
+
+
+@pytest.mark.usefixtures("mock_tempfile")
+def test_server_run_process_timeout(mock_popen: MagicMock, server: Server) -> None:
+    """Test server run where the process takes too long to terminate."""
+
+    mock_process = MagicMock()
+    mock_process.poll.return_value = None  # Simulate process running
+    # Simulate process output
+    mock_process.stdout.readline.side_effect = [
+        b"log line 1\n",
+        b"",
+    ]
+    # Simulate timeout
+    mock_process.wait.side_effect = subprocess.TimeoutExpired(cmd="go2rtc", timeout=5)
+    mock_popen.return_value.__enter__.return_value = mock_process
+
+    # Start server thread
+    server.start()
+    server.stop()
+
+    # Ensure terminate and kill were called due to timeout
+    mock_process.terminate.assert_called_once()
+    mock_process.kill.assert_called_once()
+    assert not server.is_alive()
diff --git a/tests/components/rtsp_to_webrtc/test_init.py b/tests/components/rtsp_to_webrtc/test_init.py
index 3071c3d9d083f49f962fe2492ff74216099cd987..cb4d5f7a1316ac15a11bde4af0ab6be30ee28bdd 100644
--- a/tests/components/rtsp_to_webrtc/test_init.py
+++ b/tests/components/rtsp_to_webrtc/test_init.py
@@ -10,7 +10,7 @@ import aiohttp
 import pytest
 import rtsp_to_webrtc
 
-from homeassistant.components.rtsp_to_webrtc import CONF_STUN_SERVER, DOMAIN
+from homeassistant.components.rtsp_to_webrtc import DOMAIN
 from homeassistant.components.websocket_api import TYPE_RESULT
 from homeassistant.config_entries import ConfigEntryState
 from homeassistant.core import HomeAssistant
@@ -18,7 +18,6 @@ from homeassistant.setup import async_setup_component
 
 from .conftest import SERVER_URL, STREAM_SOURCE, ComponentSetup
 
-from tests.common import MockConfigEntry
 from tests.test_util.aiohttp import AiohttpClientMocker
 from tests.typing import WebSocketGenerator
 
@@ -162,69 +161,3 @@ async def test_offer_failure(
     assert response["error"].get("code") == "web_rtc_offer_failed"
     assert "message" in response["error"]
     assert "RTSPtoWebRTC server communication failure" in response["error"]["message"]
-
-
-async def test_no_stun_server(
-    hass: HomeAssistant,
-    rtsp_to_webrtc_client: Any,
-    setup_integration: ComponentSetup,
-    hass_ws_client: WebSocketGenerator,
-) -> None:
-    """Test successful setup and unload."""
-    await setup_integration()
-
-    client = await hass_ws_client(hass)
-    await client.send_json(
-        {
-            "id": 2,
-            "type": "rtsp_to_webrtc/get_settings",
-        }
-    )
-    response = await client.receive_json()
-    assert response.get("id") == 2
-    assert response.get("type") == TYPE_RESULT
-    assert "result" in response
-    assert response["result"].get("stun_server") == ""
-
-
-@pytest.mark.parametrize(
-    "config_entry_options", [{CONF_STUN_SERVER: "example.com:1234"}]
-)
-async def test_stun_server(
-    hass: HomeAssistant,
-    rtsp_to_webrtc_client: Any,
-    setup_integration: ComponentSetup,
-    config_entry: MockConfigEntry,
-    hass_ws_client: WebSocketGenerator,
-) -> None:
-    """Test successful setup and unload."""
-    await setup_integration()
-
-    client = await hass_ws_client(hass)
-    await client.send_json(
-        {
-            "id": 3,
-            "type": "rtsp_to_webrtc/get_settings",
-        }
-    )
-    response = await client.receive_json()
-    assert response.get("id") == 3
-    assert response.get("type") == TYPE_RESULT
-    assert "result" in response
-    assert response["result"].get("stun_server") == "example.com:1234"
-
-    # Simulate an options flow change, clearing the stun server and verify the change is reflected
-    hass.config_entries.async_update_entry(config_entry, options={})
-    await hass.async_block_till_done()
-
-    await client.send_json(
-        {
-            "id": 4,
-            "type": "rtsp_to_webrtc/get_settings",
-        }
-    )
-    response = await client.receive_json()
-    assert response.get("id") == 4
-    assert response.get("type") == TYPE_RESULT
-    assert "result" in response
-    assert response["result"].get("stun_server") == ""