diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 1d933a84ebd..27bd504e9bb 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -15,6 +15,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -141,8 +142,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b firmware_coordinator=firmware_coordinator, ) - cleanup_disconnected_cams(hass, config_entry.entry_id, host) + # first migrate and then cleanup, otherwise entities lost migrate_entity_ids(hass, config_entry.entry_id, host) + cleanup_disconnected_cams(hass, config_entry.entry_id, host) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -173,6 +175,24 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok +def get_device_uid_and_ch( + device: dr.DeviceEntry, host: ReolinkHost +) -> tuple[list[str], int | None]: + """Get the channel and the split device_uid from a reolink DeviceEntry.""" + device_uid = [ + dev_id[1].split("_") for dev_id in device.identifiers if dev_id[0] == DOMAIN + ][0] + + if len(device_uid) < 2: + # NVR itself + ch = None + elif device_uid[1].startswith("ch") and len(device_uid[1]) <= 5: + ch = int(device_uid[1][2:]) + else: + ch = host.api.channel_for_uid(device_uid[1]) + return (device_uid, ch) + + def cleanup_disconnected_cams( hass: HomeAssistant, config_entry_id: str, host: ReolinkHost ) -> None: @@ -183,17 +203,10 @@ def cleanup_disconnected_cams( device_reg = dr.async_get(hass) devices = dr.async_entries_for_config_entry(device_reg, config_entry_id) for device in devices: - device_id = [ - dev_id[1].split("_ch") - for dev_id in device.identifiers - if dev_id[0] == DOMAIN - ][0] + (device_uid, ch) = get_device_uid_and_ch(device, host) + if ch is None: + continue # Do not consider the NVR itself - if len(device_id) < 2: - # Do not consider the NVR itself - continue - - ch = int(device_id[1]) ch_model = host.api.camera_model(ch) remove = False if ch not in host.api.channels: @@ -225,11 +238,54 @@ def migrate_entity_ids( hass: HomeAssistant, config_entry_id: str, host: ReolinkHost ) -> None: """Migrate entity IDs if needed.""" + device_reg = dr.async_get(hass) + devices = dr.async_entries_for_config_entry(device_reg, config_entry_id) + ch_device_ids = {} + for device in devices: + (device_uid, ch) = get_device_uid_and_ch(device, host) + + if host.api.supported(None, "UID") and device_uid[0] != host.unique_id: + if ch is None: + new_device_id = f"{host.unique_id}" + else: + new_device_id = f"{host.unique_id}_{device_uid[1]}" + new_identifiers = {(DOMAIN, new_device_id)} + device_reg.async_update_device(device.id, new_identifiers=new_identifiers) + + if ch is None: + continue # Do not consider the NVR itself + + ch_device_ids[device.id] = ch + if host.api.supported(ch, "UID") and device_uid[1] != host.api.camera_uid(ch): + if host.api.supported(None, "UID"): + new_device_id = f"{host.unique_id}_{host.api.camera_uid(ch)}" + else: + new_device_id = f"{device_uid[0]}_{host.api.camera_uid(ch)}" + new_identifiers = {(DOMAIN, new_device_id)} + device_reg.async_update_device(device.id, new_identifiers=new_identifiers) + entity_reg = er.async_get(hass) entities = er.async_entries_for_config_entry(entity_reg, config_entry_id) for entity in entities: # Can be removed in HA 2025.1.0 - if entity.domain == "update" and entity.unique_id == host.unique_id: + if entity.domain == "update" and entity.unique_id in [ + host.unique_id, + format_mac(host.api.mac_address), + ]: entity_reg.async_update_entity( entity.entity_id, new_unique_id=f"{host.unique_id}_firmware" ) + continue + + if host.api.supported(None, "UID") and not entity.unique_id.startswith( + host.unique_id + ): + new_id = f"{host.unique_id}_{entity.unique_id.split("_", 1)[1]}" + entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_id) + + if entity.device_id in ch_device_ids: + ch = ch_device_ids[entity.device_id] + id_parts = entity.unique_id.split("_", 2) + if host.api.supported(ch, "UID") and id_parts[1] != host.api.camera_uid(ch): + new_id = f"{host.unique_id}_{host.api.camera_uid(ch)}_{id_parts[2]}" + entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_id) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 29da4a55ea1..d8caff9f120 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -228,8 +228,9 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): user_input[CONF_PORT] = host.api.port user_input[CONF_USE_HTTPS] = host.api.use_https + mac_address = format_mac(host.api.mac_address) existing_entry = await self.async_set_unique_id( - host.unique_id, raise_on_progress=False + mac_address, raise_on_progress=False ) if existing_entry and self._reauth: if self.hass.config_entries.async_update_entry( diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 89c98ad0885..cf582c69e2d 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -112,17 +112,25 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): super().__init__(reolink_data, coordinator) self._channel = channel - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{self.entity_description.key}" - ) + if self._host.api.supported(channel, "UID"): + self._attr_unique_id = f"{self._host.unique_id}_{self._host.api.camera_uid(channel)}_{self.entity_description.key}" + else: + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{self.entity_description.key}" + ) dev_ch = channel if self._host.api.model in DUAL_LENS_MODELS: dev_ch = 0 if self._host.api.is_nvr: + if self._host.api.supported(dev_ch, "UID"): + dev_id = f"{self._host.unique_id}_{self._host.api.camera_uid(dev_ch)}" + else: + dev_id = f"{self._host.unique_id}_ch{dev_ch}" + self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{self._host.unique_id}_ch{dev_ch}")}, + identifiers={(DOMAIN, dev_id)}, via_device=(DOMAIN, self._host.unique_id), name=self._host.api.camera_name(dev_ch), model=self._host.api.camera_model(dev_ch), diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 9836c5d7a01..c69a80ce972 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -191,7 +191,10 @@ class ReolinkHost: else: ir.async_delete_issue(self._hass, DOMAIN, "enable_port") - self._unique_id = format_mac(self._api.mac_address) + if self._api.supported(None, "UID"): + self._unique_id = self._api.uid + else: + self._unique_id = format_mac(self._api.mac_address) if self._onvif_push_supported: try: diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 5d3c16b00fd..7a77e482f56 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -164,10 +164,14 @@ class ReolinkVODMediaSource(MediaSource): continue device = device_reg.async_get(entity.device_id) - ch = entity.unique_id.split("_")[1] - if ch in channels or device is None: + ch_id = entity.unique_id.split("_")[1] + if ch_id in channels or device is None: continue - channels.append(ch) + channels.append(ch_id) + + ch: int | str = ch_id + if len(ch_id) > 3: + ch = host.api.channel_for_uid(ch_id) if ( host.api.api_version("recReplay", int(ch)) < 1 diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index f1a8de09509..9dfce88f93a 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -330,8 +330,6 @@ class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity): self.entity_description = entity_description super().__init__(reolink_data) - self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}" - @property def is_on(self) -> bool: """Return true if switch is on.""" diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 4fed102b320..3541aa1f856 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -29,6 +29,7 @@ TEST_MAC = "aa:bb:cc:dd:ee:ff" TEST_MAC2 = "ff:ee:dd:cc:bb:aa" DHCP_FORMATTED_MAC = "aabbccddeeff" TEST_UID = "ABC1234567D89EFG" +TEST_UID_CAM = "DEF7654321D89GHT" TEST_PORT = 1234 TEST_NVR_NAME = "test_reolink_name" TEST_NVR_NAME2 = "test2_reolink_name" @@ -86,7 +87,8 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.camera_name.return_value = TEST_NVR_NAME host_mock.camera_hardware_version.return_value = "IPC_00001" host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000" - host_mock.camera_uid.return_value = TEST_UID + host_mock.camera_uid.return_value = TEST_UID_CAM + host_mock.channel_for_uid.return_value = 0 host_mock.get_encoding.return_value = "h264" host_mock.firmware_update_available.return_value = False host_mock.session_active = True diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index db6069b097c..466836e52ef 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -20,7 +20,14 @@ from homeassistant.helpers import ( from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .conftest import TEST_CAM_MODEL, TEST_HOST_MODEL, TEST_MAC, TEST_NVR_NAME +from .conftest import ( + TEST_CAM_MODEL, + TEST_HOST_MODEL, + TEST_MAC, + TEST_NVR_NAME, + TEST_UID, + TEST_UID_CAM, +) from tests.common import MockConfigEntry, async_fire_time_changed @@ -178,17 +185,104 @@ async def test_cleanup_disconnected_cams( assert sorted(device_models) == sorted(expected_models) +@pytest.mark.parametrize( + ( + "original_id", + "new_id", + "original_dev_id", + "new_dev_id", + "domain", + "support_uid", + "support_ch_uid", + ), + [ + ( + TEST_MAC, + f"{TEST_MAC}_firmware", + f"{TEST_MAC}", + f"{TEST_MAC}", + Platform.UPDATE, + False, + False, + ), + ( + TEST_MAC, + f"{TEST_UID}_firmware", + f"{TEST_MAC}", + f"{TEST_UID}", + Platform.UPDATE, + True, + False, + ), + ( + f"{TEST_MAC}_0_record_audio", + f"{TEST_UID}_0_record_audio", + f"{TEST_MAC}_ch0", + f"{TEST_UID}_ch0", + Platform.SWITCH, + True, + False, + ), + ( + f"{TEST_MAC}_0_record_audio", + f"{TEST_MAC}_{TEST_UID_CAM}_record_audio", + f"{TEST_MAC}_ch0", + f"{TEST_MAC}_{TEST_UID_CAM}", + Platform.SWITCH, + False, + True, + ), + ( + f"{TEST_MAC}_0_record_audio", + f"{TEST_UID}_{TEST_UID_CAM}_record_audio", + f"{TEST_MAC}_ch0", + f"{TEST_UID}_{TEST_UID_CAM}", + Platform.SWITCH, + True, + True, + ), + ( + f"{TEST_UID}_0_record_audio", + f"{TEST_UID}_{TEST_UID_CAM}_record_audio", + f"{TEST_UID}_ch0", + f"{TEST_UID}_{TEST_UID_CAM}", + Platform.SWITCH, + True, + True, + ), + ], +) async def test_migrate_entity_ids( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock, entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + original_id: str, + new_id: str, + original_dev_id: str, + new_dev_id: str, + domain: Platform, + support_uid: bool, + support_ch_uid: bool, ) -> None: """Test entity ids that need to be migrated.""" + + def mock_supported(ch, capability): + if capability == "UID" and ch is None: + return support_uid + if capability == "UID": + return support_ch_uid + return True + reolink_connect.channels = [0] - original_id = f"{TEST_MAC}" - new_id = f"{TEST_MAC}_firmware" - domain = Platform.UPDATE + reolink_connect.supported = mock_supported + + dev_entry = device_registry.async_get_or_create( + identifiers={(const.DOMAIN, original_dev_id)}, + config_entry_id=config_entry.entry_id, + disabled_by=None, + ) entity_registry.async_get_or_create( domain=domain, @@ -197,11 +291,21 @@ async def test_migrate_entity_ids( config_entry=config_entry, suggested_object_id=original_id, disabled_by=None, + device_id=dev_entry.id, ) assert entity_registry.async_get_entity_id(domain, const.DOMAIN, original_id) assert entity_registry.async_get_entity_id(domain, const.DOMAIN, new_id) is None + assert device_registry.async_get_device( + identifiers={(const.DOMAIN, original_dev_id)} + ) + if new_dev_id != original_dev_id: + assert ( + device_registry.async_get_device(identifiers={(const.DOMAIN, new_dev_id)}) + is None + ) + # setup CH 0 and host entities/device with patch("homeassistant.components.reolink.PLATFORMS", [domain]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -212,6 +316,15 @@ async def test_migrate_entity_ids( ) assert entity_registry.async_get_entity_id(domain, const.DOMAIN, new_id) + if new_dev_id != original_dev_id: + assert ( + device_registry.async_get_device( + identifiers={(const.DOMAIN, original_dev_id)} + ) + is None + ) + assert device_registry.async_get_device(identifiers={(const.DOMAIN, new_dev_id)}) + async def test_no_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry