From b15e08ca9c887ae041683eb58b36429213ef3102 Mon Sep 17 00:00:00 2001
From: Nerdix <70015952+N3rdix@users.noreply.github.com>
Date: Fri, 3 Jan 2025 20:15:09 +0100
Subject: [PATCH] Add sleep switch for all Foscam cameras if more than 1 camera
 are configured (#126064)

---
 homeassistant/components/foscam/__init__.py |  26 ++++-
 homeassistant/components/foscam/switch.py   |   2 +-
 tests/components/foscam/conftest.py         |  66 +++++++++++
 tests/components/foscam/const.py            |  21 ++++
 tests/components/foscam/test_config_flow.py |  70 +-----------
 tests/components/foscam/test_init.py        | 118 ++++++++++++++++++++
 6 files changed, 234 insertions(+), 69 deletions(-)
 create mode 100644 tests/components/foscam/conftest.py
 create mode 100644 tests/components/foscam/const.py
 create mode 100644 tests/components/foscam/test_init.py

diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py
index b4d64464972..09df989447a 100644
--- a/homeassistant/components/foscam/__init__.py
+++ b/homeassistant/components/foscam/__init__.py
@@ -11,7 +11,7 @@ from homeassistant.const import (
     Platform,
 )
 from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_registry import async_migrate_entries
+from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
 
 from .config_flow import DEFAULT_RTSP_PORT
 from .const import CONF_RTSP_PORT, DOMAIN, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET
@@ -36,6 +36,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 
     hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
 
+    # Migrate to correct unique IDs for switches
+    await async_migrate_entities(hass, entry)
+
     await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
 
     return True
@@ -92,3 +95,24 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
     LOGGER.debug("Migration to version %s successful", entry.version)
 
     return True
+
+
+async def async_migrate_entities(hass: HomeAssistant, entry: ConfigEntry) -> None:
+    """Migrate old entry."""
+
+    @callback
+    def _update_unique_id(
+        entity_entry: RegistryEntry,
+    ) -> dict[str, str] | None:
+        """Update unique ID of entity entry."""
+        if (
+            entity_entry.domain == Platform.SWITCH
+            and entity_entry.unique_id == "sleep_switch"
+        ):
+            entity_new_unique_id = f"{entity_entry.config_entry_id}_sleep_switch"
+            return {"new_unique_id": entity_new_unique_id}
+
+        return None
+
+    # Migrate entities
+    await async_migrate_entries(hass, entry.entry_id, _update_unique_id)
diff --git a/homeassistant/components/foscam/switch.py b/homeassistant/components/foscam/switch.py
index 9eae211881f..dfc51aaa064 100644
--- a/homeassistant/components/foscam/switch.py
+++ b/homeassistant/components/foscam/switch.py
@@ -41,7 +41,7 @@ class FoscamSleepSwitch(FoscamEntity, SwitchEntity):
         """Initialize a Foscam Sleep Switch."""
         super().__init__(coordinator, config_entry.entry_id)
 
-        self._attr_unique_id = "sleep_switch"
+        self._attr_unique_id = f"{config_entry.entry_id}_sleep_switch"
         self._attr_translation_key = "sleep_switch"
         self._attr_has_entity_name = True
 
diff --git a/tests/components/foscam/conftest.py b/tests/components/foscam/conftest.py
new file mode 100644
index 00000000000..6ff5a0b5af5
--- /dev/null
+++ b/tests/components/foscam/conftest.py
@@ -0,0 +1,66 @@
+"""Common stuff for Foscam tests."""
+
+from libpyfoscam.foscam import (
+    ERROR_FOSCAM_AUTH,
+    ERROR_FOSCAM_CMD,
+    ERROR_FOSCAM_UNAVAILABLE,
+    ERROR_FOSCAM_UNKNOWN,
+)
+
+from homeassistant.components.foscam import config_flow
+
+from .const import (
+    CAMERA_MAC,
+    CAMERA_NAME,
+    INVALID_RESPONSE_CONFIG,
+    OPERATOR_CONFIG,
+    VALID_CONFIG,
+)
+
+
+def setup_mock_foscam_camera(mock_foscam_camera):
+    """Mock FoscamCamera simulating behaviour using a base valid config."""
+
+    def configure_mock_on_init(host, port, user, passwd, verbose=False):
+        product_all_info_rc = 0
+        dev_info_rc = 0
+        dev_info_data = {}
+
+        if (
+            host != VALID_CONFIG[config_flow.CONF_HOST]
+            or port != VALID_CONFIG[config_flow.CONF_PORT]
+        ):
+            product_all_info_rc = dev_info_rc = ERROR_FOSCAM_UNAVAILABLE
+
+        elif (
+            user
+            not in [
+                VALID_CONFIG[config_flow.CONF_USERNAME],
+                OPERATOR_CONFIG[config_flow.CONF_USERNAME],
+                INVALID_RESPONSE_CONFIG[config_flow.CONF_USERNAME],
+            ]
+            or passwd != VALID_CONFIG[config_flow.CONF_PASSWORD]
+        ):
+            product_all_info_rc = dev_info_rc = ERROR_FOSCAM_AUTH
+
+        elif user == INVALID_RESPONSE_CONFIG[config_flow.CONF_USERNAME]:
+            product_all_info_rc = dev_info_rc = ERROR_FOSCAM_UNKNOWN
+
+        elif user == OPERATOR_CONFIG[config_flow.CONF_USERNAME]:
+            dev_info_rc = ERROR_FOSCAM_CMD
+
+        else:
+            dev_info_data["devName"] = CAMERA_NAME
+            dev_info_data["mac"] = CAMERA_MAC
+            dev_info_data["productName"] = "Foscam Product"
+            dev_info_data["firmwareVer"] = "1.2.3"
+            dev_info_data["hardwareVer"] = "4.5.6"
+
+        mock_foscam_camera.get_product_all_info.return_value = (product_all_info_rc, {})
+        mock_foscam_camera.get_dev_info.return_value = (dev_info_rc, dev_info_data)
+        mock_foscam_camera.get_port_info.return_value = (dev_info_rc, {})
+        mock_foscam_camera.is_asleep.return_value = (0, True)
+
+        return mock_foscam_camera
+
+    mock_foscam_camera.side_effect = configure_mock_on_init
diff --git a/tests/components/foscam/const.py b/tests/components/foscam/const.py
new file mode 100644
index 00000000000..2f00dad5932
--- /dev/null
+++ b/tests/components/foscam/const.py
@@ -0,0 +1,21 @@
+"""Constants for Foscam tests."""
+
+from homeassistant.components.foscam import config_flow
+
+VALID_CONFIG = {
+    config_flow.CONF_HOST: "10.0.0.2",
+    config_flow.CONF_PORT: 88,
+    config_flow.CONF_USERNAME: "admin",
+    config_flow.CONF_PASSWORD: "1234",
+    config_flow.CONF_STREAM: "Main",
+    config_flow.CONF_RTSP_PORT: 554,
+}
+OPERATOR_CONFIG = {
+    config_flow.CONF_USERNAME: "operator",
+}
+INVALID_RESPONSE_CONFIG = {
+    config_flow.CONF_USERNAME: "interr",
+}
+CAMERA_NAME = "Mocked Foscam Camera"
+CAMERA_MAC = "C0:C1:D0:F4:B4:D4"
+ENTRY_ID = "123ABC"
diff --git a/tests/components/foscam/test_config_flow.py b/tests/components/foscam/test_config_flow.py
index 9c0a07aa67c..235a2741992 100644
--- a/tests/components/foscam/test_config_flow.py
+++ b/tests/components/foscam/test_config_flow.py
@@ -2,79 +2,15 @@
 
 from unittest.mock import patch
 
-from libpyfoscam.foscam import (
-    ERROR_FOSCAM_AUTH,
-    ERROR_FOSCAM_CMD,
-    ERROR_FOSCAM_UNAVAILABLE,
-    ERROR_FOSCAM_UNKNOWN,
-)
-
 from homeassistant import config_entries
 from homeassistant.components.foscam import config_flow
 from homeassistant.core import HomeAssistant
 from homeassistant.data_entry_flow import FlowResultType
 
-from tests.common import MockConfigEntry
+from .conftest import setup_mock_foscam_camera
+from .const import CAMERA_NAME, INVALID_RESPONSE_CONFIG, VALID_CONFIG
 
-VALID_CONFIG = {
-    config_flow.CONF_HOST: "10.0.0.2",
-    config_flow.CONF_PORT: 88,
-    config_flow.CONF_USERNAME: "admin",
-    config_flow.CONF_PASSWORD: "1234",
-    config_flow.CONF_STREAM: "Main",
-    config_flow.CONF_RTSP_PORT: 554,
-}
-OPERATOR_CONFIG = {
-    config_flow.CONF_USERNAME: "operator",
-}
-INVALID_RESPONSE_CONFIG = {
-    config_flow.CONF_USERNAME: "interr",
-}
-CAMERA_NAME = "Mocked Foscam Camera"
-CAMERA_MAC = "C0:C1:D0:F4:B4:D4"
-
-
-def setup_mock_foscam_camera(mock_foscam_camera):
-    """Mock FoscamCamera simulating behaviour using a base valid config."""
-
-    def configure_mock_on_init(host, port, user, passwd, verbose=False):
-        product_all_info_rc = 0
-        dev_info_rc = 0
-        dev_info_data = {}
-
-        if (
-            host != VALID_CONFIG[config_flow.CONF_HOST]
-            or port != VALID_CONFIG[config_flow.CONF_PORT]
-        ):
-            product_all_info_rc = dev_info_rc = ERROR_FOSCAM_UNAVAILABLE
-
-        elif (
-            user
-            not in [
-                VALID_CONFIG[config_flow.CONF_USERNAME],
-                OPERATOR_CONFIG[config_flow.CONF_USERNAME],
-                INVALID_RESPONSE_CONFIG[config_flow.CONF_USERNAME],
-            ]
-            or passwd != VALID_CONFIG[config_flow.CONF_PASSWORD]
-        ):
-            product_all_info_rc = dev_info_rc = ERROR_FOSCAM_AUTH
-
-        elif user == INVALID_RESPONSE_CONFIG[config_flow.CONF_USERNAME]:
-            product_all_info_rc = dev_info_rc = ERROR_FOSCAM_UNKNOWN
-
-        elif user == OPERATOR_CONFIG[config_flow.CONF_USERNAME]:
-            dev_info_rc = ERROR_FOSCAM_CMD
-
-        else:
-            dev_info_data["devName"] = CAMERA_NAME
-            dev_info_data["mac"] = CAMERA_MAC
-
-        mock_foscam_camera.get_product_all_info.return_value = (product_all_info_rc, {})
-        mock_foscam_camera.get_dev_info.return_value = (dev_info_rc, dev_info_data)
-
-        return mock_foscam_camera
-
-    mock_foscam_camera.side_effect = configure_mock_on_init
+from tests.common import MockConfigEntry
 
 
 async def test_user_valid(hass: HomeAssistant) -> None:
diff --git a/tests/components/foscam/test_init.py b/tests/components/foscam/test_init.py
new file mode 100644
index 00000000000..0b82ed3b02a
--- /dev/null
+++ b/tests/components/foscam/test_init.py
@@ -0,0 +1,118 @@
+"""Test the Foscam component."""
+
+from unittest.mock import patch
+
+from homeassistant.components.foscam import DOMAIN, config_flow
+from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+from .conftest import setup_mock_foscam_camera
+from .const import ENTRY_ID, VALID_CONFIG
+
+from tests.common import MockConfigEntry
+
+
+async def test_unique_id_new_entry(
+    hass: HomeAssistant,
+    entity_registry: er.EntityRegistry,
+) -> None:
+    """Test unique ID for a newly added device is correct."""
+    entry = MockConfigEntry(
+        domain=config_flow.DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID
+    )
+    entry.add_to_hass(hass)
+
+    with (
+        # Mock a valid camera instance"
+        patch("homeassistant.components.foscam.FoscamCamera") as mock_foscam_camera,
+    ):
+        setup_mock_foscam_camera(mock_foscam_camera)
+        assert await hass.config_entries.async_setup(entry.entry_id)
+
+    await hass.async_block_till_done()
+
+    # Test that unique_id remains the same.
+    entity_id = entity_registry.async_get_entity_id(
+        SWITCH_DOMAIN, DOMAIN, f"{ENTRY_ID}_sleep_switch"
+    )
+    entity_new = entity_registry.async_get(entity_id)
+    assert entity_new.unique_id == f"{ENTRY_ID}_sleep_switch"
+
+
+async def test_switch_unique_id_migration_ok(
+    hass: HomeAssistant,
+    entity_registry: er.EntityRegistry,
+) -> None:
+    """Test that the unique ID for a sleep switch is migrated to the new format."""
+    entry = MockConfigEntry(
+        domain=config_flow.DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID, version=1
+    )
+    entry.add_to_hass(hass)
+
+    entity_before = entity_registry.async_get_or_create(
+        SWITCH_DOMAIN, DOMAIN, "sleep_switch", config_entry=entry
+    )
+    assert entity_before.unique_id == "sleep_switch"
+
+    # Update config entry with version 2
+    entry = MockConfigEntry(
+        domain=config_flow.DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID, version=2
+    )
+    entry.add_to_hass(hass)
+
+    with (
+        # Mock a valid camera instance"
+        patch("homeassistant.components.foscam.FoscamCamera") as mock_foscam_camera,
+    ):
+        setup_mock_foscam_camera(mock_foscam_camera)
+        await hass.config_entries.async_setup(entry.entry_id)
+
+    await hass.async_block_till_done()
+
+    entity_id_new = entity_registry.async_get_entity_id(
+        SWITCH_DOMAIN, DOMAIN, f"{ENTRY_ID}_sleep_switch"
+    )
+    assert hass.states.get(entity_id_new)
+    entity_after = entity_registry.async_get(entity_id_new)
+    assert entity_after.previous_unique_id == "sleep_switch"
+    assert entity_after.unique_id == f"{ENTRY_ID}_sleep_switch"
+
+
+async def test_unique_id_migration_not_needed(
+    hass: HomeAssistant,
+    entity_registry: er.EntityRegistry,
+) -> None:
+    """Test that the unique ID for a sleep switch is not executed if already in right format."""
+    entry = MockConfigEntry(
+        domain=config_flow.DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID
+    )
+    entry.add_to_hass(hass)
+
+    entity_registry.async_get_or_create(
+        SWITCH_DOMAIN, DOMAIN, f"{ENTRY_ID}_sleep_switch", config_entry=entry
+    )
+
+    entity_id = entity_registry.async_get_entity_id(
+        SWITCH_DOMAIN, DOMAIN, f"{ENTRY_ID}_sleep_switch"
+    )
+    entity_before = entity_registry.async_get(entity_id)
+    assert entity_before.unique_id == f"{ENTRY_ID}_sleep_switch"
+
+    with (
+        # Mock a valid camera instance"
+        patch("homeassistant.components.foscam.FoscamCamera") as mock_foscam_camera,
+        patch(
+            "homeassistant.components.foscam.async_migrate_entry",
+            return_value=True,
+        ),
+    ):
+        setup_mock_foscam_camera(mock_foscam_camera)
+        assert await hass.config_entries.async_setup(entry.entry_id)
+
+    await hass.async_block_till_done()
+
+    # Test that unique_id remains the same.
+    assert hass.states.get(entity_id)
+    entity_after = entity_registry.async_get(entity_id)
+    assert entity_after.unique_id == entity_before.unique_id
-- 
GitLab