From a3d08721bb22eb5ff9c113268618bcbf0d52d204 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 20 Apr 2026 09:16:10 +0000 Subject: [PATCH 1/5] User camera unique id in go2rtc if available --- homeassistant/components/go2rtc/__init__.py | 13 +++--- homeassistant/components/go2rtc/util.py | 12 ++++++ tests/components/go2rtc/__init__.py | 3 +- tests/components/go2rtc/conftest.py | 9 +++- tests/components/go2rtc/test_init.py | 48 +++++++++++++++------ tests/components/go2rtc/test_util.py | 41 ++++++++++++++++++ 6 files changed, 106 insertions(+), 20 deletions(-) create mode 100644 tests/components/go2rtc/test_util.py diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index f27dad3bd70adb..db8cdb41191ec3 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -67,7 +67,7 @@ RECOMMENDED_VERSION, ) from .server import Server -from .util import get_go2rtc_unix_socket_path +from .util import get_camera_identifier, get_go2rtc_unix_socket_path _LOGGER = logging.getLogger(__name__) @@ -308,7 +308,7 @@ async def async_handle_async_webrtc_offer( return self._sessions[session_id] = ws_client = Go2RtcWsClient( - self._session, self._url, source=camera.entity_id + self._session, self._url, source=get_camera_identifier(camera) ) @callback @@ -354,7 +354,7 @@ async def async_get_image( """Get an image from the camera.""" await self._update_stream_source(camera) return await self._rest_client.get_jpeg_snapshot( - camera.entity_id, width, height + get_camera_identifier(camera), width, height ) async def _update_stream_source(self, camera: Camera) -> None: @@ -399,18 +399,19 @@ async def _update_stream_source(self, camera: Camera) -> None: stream_source += "#rotate=90" streams = await self._rest_client.streams.list() + identifier = get_camera_identifier(camera) - if (stream := streams.get(camera.entity_id)) is None or not any( + 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( - camera.entity_id, + 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:{camera.entity_id}#audio=opus#query=log_level=debug", + f"ffmpeg:{identifier}#audio=opus#query=log_level=debug", ], ) diff --git a/homeassistant/components/go2rtc/util.py b/homeassistant/components/go2rtc/util.py index 6e47075dbf90be..de8460a7a62d97 100644 --- a/homeassistant/components/go2rtc/util.py +++ b/homeassistant/components/go2rtc/util.py @@ -1,8 +1,14 @@ """Go2rtc utility functions.""" from pathlib import Path +import re + +from homeassistant.components.camera import Camera _HA_MANAGED_UNIX_SOCKET_FILE = "go2rtc.sock" +# Go2rtc is not validating the camera identifier, but some characters (e.g. : or #) +# have special meaning in URLs and could cause issues. +_SANITIZE_IDENTIFIER = re.compile(r"[^a-zA-Z0-9._-]") def get_go2rtc_unix_socket_path(path: str | Path) -> str: @@ -10,3 +16,9 @@ def get_go2rtc_unix_socket_path(path: str | Path) -> str: if not isinstance(path, Path): path = Path(path) return str(path / _HA_MANAGED_UNIX_SOCKET_FILE) + + +def get_camera_identifier(camera: Camera) -> str: + """Get the Go2rtc camera identifier.""" + attr = camera.unique_id or camera.entity_id + return _SANITIZE_IDENTIFIER.sub(lambda m: f"%{ord(m.group()):02X}", attr) diff --git a/tests/components/go2rtc/__init__.py b/tests/components/go2rtc/__init__.py index 26a8c467c0db51..c7c07be1f77ca4 100644 --- a/tests/components/go2rtc/__init__.py +++ b/tests/components/go2rtc/__init__.py @@ -9,10 +9,11 @@ class MockCamera(Camera): _attr_name = "Test" _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM - def __init__(self) -> None: + def __init__(self, unique_id: str | None) -> None: """Initialize the mock entity.""" super().__init__() self._stream_source: str | None = "rtsp://stream" + self._attr_unique_id = unique_id def set_stream_source(self, stream_source: str | None) -> None: """Set the stream source.""" diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index 3186a0536f4506..09be8a8467b567 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -185,10 +185,17 @@ def integration_config_entry(hass: HomeAssistant) -> ConfigEntry: return entry +@pytest.fixture +def camera_unique_id() -> str | None: + """Camera unique ID.""" + return "test_camera_unique_id" + + @pytest.fixture async def init_test_integration( hass: HomeAssistant, integration_config_entry: ConfigEntry, + camera_unique_id: str | None, ) -> MockCamera: """Initialize components.""" @@ -218,7 +225,7 @@ async def async_unload_entry_init( async_unload_entry=async_unload_entry_init, ), ) - test_camera = MockCamera() + test_camera = MockCamera(camera_unique_id) setup_test_component_platform( hass, CAMERA_DOMAIN, [test_camera], from_config_entry=True ) diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 63c9b31ef56ef2..29c259b07b45c6 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -42,7 +42,10 @@ DOMAIN, RECOMMENDED_VERSION, ) -from homeassistant.components.go2rtc.util import get_go2rtc_unix_socket_path +from homeassistant.components.go2rtc.util import ( + get_camera_identifier, + get_go2rtc_unix_socket_path, +) from homeassistant.components.stream import Orientation from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME @@ -85,9 +88,10 @@ async def _test_setup_and_signaling( config: ConfigType, after_setup_fn: Callable[[], None], camera: MockCamera, + camera_unique_id: str | None, ) -> None: """Test the go2rtc config entry.""" - entity_id = camera.entity_id + identifier = camera_unique_id or camera.entity_id assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS} assert await async_setup_component(hass, DOMAIN, config) @@ -124,17 +128,17 @@ async def test(session: str) -> None: await test("sesion_1") rest_client.streams.add.assert_called_once_with( - entity_id, + identifier, [ "rtsp://stream", - f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + f"ffmpeg:{identifier}#audio=opus#query=log_level=debug", ], ) # Stream exists but the source is different rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - entity_id: Stream([Producer("rtsp://different")]) + identifier: Stream([Producer("rtsp://different")]) } receive_message_callback.reset_mock() @@ -142,17 +146,17 @@ async def test(session: str) -> None: await test("session_2") rest_client.streams.add.assert_called_once_with( - entity_id, + identifier, [ "rtsp://stream", - f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + f"ffmpeg:{identifier}#audio=opus#query=log_level=debug", ], ) # If the stream is already added, the stream should not be added again. rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - entity_id: Stream([Producer("rtsp://stream")]) + identifier: Stream([Producer("rtsp://stream")]) } receive_message_callback.reset_mock() @@ -192,6 +196,13 @@ async def test(session: str) -> None: "mock_is_docker_env", "mock_go2rtc_entry", ) +@pytest.mark.parametrize( + "camera_unique_id", + [ + "test_camera_unique_id", + None, + ], +) @pytest.mark.parametrize( ("config", "ui_enabled", "expected_username", "expected_password"), [ @@ -245,6 +256,7 @@ async def test_setup_go_binary( ui_enabled: bool, expected_username: str, expected_password: str, + camera_unique_id: str | None, ) -> None: """Test the go2rtc config entry with binary.""" assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry @@ -276,6 +288,7 @@ def after_setup() -> None: config, after_setup, init_test_integration, + camera_unique_id, ) await hass.async_stop() @@ -283,6 +296,13 @@ def after_setup() -> None: @pytest.mark.usefixtures("mock_go2rtc_entry") +@pytest.mark.parametrize( + "camera_unique_id", + [ + "test_camera_unique_id", + None, + ], +) @pytest.mark.parametrize( ("go2rtc_binary", "is_docker_env"), [ @@ -301,6 +321,7 @@ async def test_setup( mock_get_binary: Mock, mock_is_docker_env: Mock, has_go2rtc_entry: bool, + camera_unique_id: str | None, ) -> None: """Test the go2rtc config entry without binary.""" assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry @@ -318,6 +339,7 @@ def after_setup() -> None: config, after_setup, init_test_integration, + camera_unique_id, ) mock_get_binary.assert_not_called() @@ -816,11 +838,12 @@ async def test_generic_workaround( image = await async_get_image(hass, camera.entity_id) assert image.content == image_bytes + identifier = get_camera_identifier(camera) rest_client.streams.add.assert_called_once_with( - camera.entity_id, + identifier, [ "ffmpeg:https://my_stream_url.m3u8", - f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + f"ffmpeg:{identifier}#audio=opus#query=log_level=debug", ], ) @@ -849,11 +872,12 @@ async def _test_camera_orientation( await camera_fn(hass, camera) # Verify the stream was configured correctly + identifier = get_camera_identifier(camera) rest_client.streams.add.assert_called_once_with( - camera.entity_id, + identifier, [ expected_stream_source, - f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + f"ffmpeg:{identifier}#audio=opus#query=log_level=debug", ], ) diff --git a/tests/components/go2rtc/test_util.py b/tests/components/go2rtc/test_util.py new file mode 100644 index 00000000000000..0aad0d9c54576d --- /dev/null +++ b/tests/components/go2rtc/test_util.py @@ -0,0 +1,41 @@ +"""Go2rtc utility function tests.""" + +from unittest.mock import Mock + +import pytest + +from homeassistant.components.camera import Camera +from homeassistant.components.go2rtc.util import get_camera_identifier + + +@pytest.mark.parametrize( + ("unique_id", "entity_id", "expected"), + [ + # Prefer unique_id over entity_id + ("unique123", "camera.test", "unique123"), + # Fall back to entity_id when unique_id is None + (None, "camera.test", "camera.test"), + # Safe characters pass through + ("abc-def_ghi.123", "camera.test", "abc-def_ghi.123"), + # Special characters are percent-encoded + ("cam#1", "camera.test", "cam%231"), + ("cam:1", "camera.test", "cam%3A1"), + ("cam/1", "camera.test", "cam%2F1"), + ("cam?1", "camera.test", "cam%3F1"), + ("cam&1", "camera.test", "cam%261"), + ("cam=1", "camera.test", "cam%3D1"), + ("cam%1", "camera.test", "cam%251"), + ("cam 1", "camera.test", "cam%201"), + ("cam@1", "camera.test", "cam%401"), + ("cam_1", "camera.test", "cam_1"), + ("cam%231", "camera.test", "cam%25231"), + ], +) +def test_get_camera_identifier( + unique_id: str | None, entity_id: str, expected: str +) -> None: + """Test get_camera_identifier sanitizes and prefers unique_id.""" + camera = Mock(spec=Camera) + camera.unique_id = unique_id + camera.entity_id = entity_id + assert get_camera_identifier(camera) == expected From 5569acb7217a706ba8b140773bdd5225e1d454d1 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 20 Apr 2026 09:45:03 +0000 Subject: [PATCH 2/5] Implement suggestions --- homeassistant/components/go2rtc/util.py | 7 ++++--- tests/components/go2rtc/test_init.py | 7 +------ tests/components/go2rtc/test_util.py | 2 ++ 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/go2rtc/util.py b/homeassistant/components/go2rtc/util.py index de8460a7a62d97..1d0df24c67eced 100644 --- a/homeassistant/components/go2rtc/util.py +++ b/homeassistant/components/go2rtc/util.py @@ -1,14 +1,15 @@ """Go2rtc utility functions.""" from pathlib import Path -import re +import string +from urllib.parse import quote from homeassistant.components.camera import Camera _HA_MANAGED_UNIX_SOCKET_FILE = "go2rtc.sock" # Go2rtc is not validating the camera identifier, but some characters (e.g. : or #) # have special meaning in URLs and could cause issues. -_SANITIZE_IDENTIFIER = re.compile(r"[^a-zA-Z0-9._-]") +_SAFE_CHARS = string.ascii_letters + string.digits + "._-" def get_go2rtc_unix_socket_path(path: str | Path) -> str: @@ -21,4 +22,4 @@ def get_go2rtc_unix_socket_path(path: str | Path) -> str: def get_camera_identifier(camera: Camera) -> str: """Get the Go2rtc camera identifier.""" attr = camera.unique_id or camera.entity_id - return _SANITIZE_IDENTIFIER.sub(lambda m: f"%{ord(m.group()):02X}", attr) + return quote(attr, safe=_SAFE_CHARS) diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 29c259b07b45c6..226059c3da2dad 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -88,10 +88,9 @@ async def _test_setup_and_signaling( config: ConfigType, after_setup_fn: Callable[[], None], camera: MockCamera, - camera_unique_id: str | None, ) -> None: """Test the go2rtc config entry.""" - identifier = camera_unique_id or camera.entity_id + identifier = get_camera_identifier(camera) assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS} assert await async_setup_component(hass, DOMAIN, config) @@ -256,7 +255,6 @@ async def test_setup_go_binary( ui_enabled: bool, expected_username: str, expected_password: str, - camera_unique_id: str | None, ) -> None: """Test the go2rtc config entry with binary.""" assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry @@ -288,7 +286,6 @@ def after_setup() -> None: config, after_setup, init_test_integration, - camera_unique_id, ) await hass.async_stop() @@ -321,7 +318,6 @@ async def test_setup( mock_get_binary: Mock, mock_is_docker_env: Mock, has_go2rtc_entry: bool, - camera_unique_id: str | None, ) -> None: """Test the go2rtc config entry without binary.""" assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry @@ -339,7 +335,6 @@ def after_setup() -> None: config, after_setup, init_test_integration, - camera_unique_id, ) mock_get_binary.assert_not_called() diff --git a/tests/components/go2rtc/test_util.py b/tests/components/go2rtc/test_util.py index 0aad0d9c54576d..a2a0620026a3bb 100644 --- a/tests/components/go2rtc/test_util.py +++ b/tests/components/go2rtc/test_util.py @@ -29,6 +29,8 @@ ("cam@1", "camera.test", "cam%401"), ("cam_1", "camera.test", "cam_1"), ("cam%231", "camera.test", "cam%25231"), + # Non-ASCII: UTF-8 byte-wise encoding (€ = E2 82 AC) + ("cam€1", "camera.test", "cam%E2%82%AC1"), ], ) def test_get_camera_identifier( From a325153f06b14baf8ad3312553e3e5e04c6db248 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 20 Apr 2026 12:38:12 +0000 Subject: [PATCH 3/5] code review --- tests/components/go2rtc/test_init.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 226059c3da2dad..12c11d22ae48ec 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -201,6 +201,7 @@ async def test(session: str) -> None: "test_camera_unique_id", None, ], + indirect=True, ) @pytest.mark.parametrize( ("config", "ui_enabled", "expected_username", "expected_password"), @@ -299,6 +300,7 @@ def after_setup() -> None: "test_camera_unique_id", None, ], + indirect=True, ) @pytest.mark.parametrize( ("go2rtc_binary", "is_docker_env"), From cba38984805c7f154352ef9b57ab69a6a9e89e80 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 24 Apr 2026 08:49:27 +0000 Subject: [PATCH 4/5] Prefix unique id with platform name Co-authored-by: Copilot --- homeassistant/components/go2rtc/util.py | 4 +++- tests/components/go2rtc/test_init.py | 16 ++++++------- tests/components/go2rtc/test_util.py | 31 +++++++++++++------------ 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/go2rtc/util.py b/homeassistant/components/go2rtc/util.py index 1d0df24c67eced..a19f57f4383243 100644 --- a/homeassistant/components/go2rtc/util.py +++ b/homeassistant/components/go2rtc/util.py @@ -21,5 +21,7 @@ def get_go2rtc_unix_socket_path(path: str | Path) -> str: def get_camera_identifier(camera: Camera) -> str: """Get the Go2rtc camera identifier.""" - attr = camera.unique_id or camera.entity_id + attr = camera.entity_id + if camera.unique_id is not None: + attr = f"{camera.platform.platform_name}_{camera.unique_id}" return quote(attr, safe=_SAFE_CHARS) diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 12c11d22ae48ec..d8f421884eaae4 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -835,14 +835,14 @@ async def test_generic_workaround( image = await async_get_image(hass, camera.entity_id) assert image.content == image_bytes - identifier = get_camera_identifier(camera) - rest_client.streams.add.assert_called_once_with( - identifier, - [ - "ffmpeg:https://my_stream_url.m3u8", - f"ffmpeg:{identifier}#audio=opus#query=log_level=debug", - ], - ) + identifier = get_camera_identifier(camera) + rest_client.streams.add.assert_called_once_with( + identifier, + [ + "ffmpeg:https://my_stream_url.m3u8", + f"ffmpeg:{identifier}#audio=opus#query=log_level=debug", + ], + ) async def _test_camera_orientation( diff --git a/tests/components/go2rtc/test_util.py b/tests/components/go2rtc/test_util.py index a2a0620026a3bb..98b8089a07d450 100644 --- a/tests/components/go2rtc/test_util.py +++ b/tests/components/go2rtc/test_util.py @@ -12,32 +12,33 @@ ("unique_id", "entity_id", "expected"), [ # Prefer unique_id over entity_id - ("unique123", "camera.test", "unique123"), + ("unique123", "camera.test", "test_unique123"), # Fall back to entity_id when unique_id is None (None, "camera.test", "camera.test"), # Safe characters pass through - ("abc-def_ghi.123", "camera.test", "abc-def_ghi.123"), + ("abc-def_ghi.123", "camera.test", "test_abc-def_ghi.123"), # Special characters are percent-encoded - ("cam#1", "camera.test", "cam%231"), - ("cam:1", "camera.test", "cam%3A1"), - ("cam/1", "camera.test", "cam%2F1"), - ("cam?1", "camera.test", "cam%3F1"), - ("cam&1", "camera.test", "cam%261"), - ("cam=1", "camera.test", "cam%3D1"), - ("cam%1", "camera.test", "cam%251"), - ("cam 1", "camera.test", "cam%201"), - ("cam@1", "camera.test", "cam%401"), - ("cam_1", "camera.test", "cam_1"), - ("cam%231", "camera.test", "cam%25231"), + ("cam#1", "camera.test", "test_cam%231"), + ("cam:1", "camera.test", "test_cam%3A1"), + ("cam/1", "camera.test", "test_cam%2F1"), + ("cam?1", "camera.test", "test_cam%3F1"), + ("cam&1", "camera.test", "test_cam%261"), + ("cam=1", "camera.test", "test_cam%3D1"), + ("cam%1", "camera.test", "test_cam%251"), + ("cam 1", "camera.test", "test_cam%201"), + ("cam@1", "camera.test", "test_cam%401"), + ("cam_1", "camera.test", "test_cam_1"), + ("cam%231", "camera.test", "test_cam%25231"), # Non-ASCII: UTF-8 byte-wise encoding (€ = E2 82 AC) - ("cam€1", "camera.test", "cam%E2%82%AC1"), + ("cam€1", "camera.test", "test_cam%E2%82%AC1"), ], ) def test_get_camera_identifier( unique_id: str | None, entity_id: str, expected: str ) -> None: """Test get_camera_identifier sanitizes and prefers unique_id.""" - camera = Mock(spec=Camera) + camera = Mock(spec_set=Camera) + camera.platform.platform_name = "test" camera.unique_id = unique_id camera.entity_id = entity_id assert get_camera_identifier(camera) == expected From 1adc99dceda1915e1451e1475d58e5751fc2ce7c Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 24 Apr 2026 08:55:10 +0000 Subject: [PATCH 5/5] Rename test unique id --- tests/components/go2rtc/conftest.py | 2 +- tests/components/go2rtc/test_init.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index 09be8a8467b567..12292a75221d60 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -188,7 +188,7 @@ def integration_config_entry(hass: HomeAssistant) -> ConfigEntry: @pytest.fixture def camera_unique_id() -> str | None: """Camera unique ID.""" - return "test_camera_unique_id" + return "camera_unique_id" @pytest.fixture diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index d8f421884eaae4..7a5a37e60222d3 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -198,7 +198,7 @@ async def test(session: str) -> None: @pytest.mark.parametrize( "camera_unique_id", [ - "test_camera_unique_id", + "camera_unique_id", None, ], indirect=True, @@ -297,7 +297,7 @@ def after_setup() -> None: @pytest.mark.parametrize( "camera_unique_id", [ - "test_camera_unique_id", + "camera_unique_id", None, ], indirect=True,