diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index d05f544ada1..a24777f9ecd 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -128,5 +128,5 @@ async def async_remove_config_entry_device( assert api is not None return api.bootstrap.nvr.mac not in unifi_macs and not any( device.mac in unifi_macs - for device in async_get_devices(api, DEVICES_THAT_ADOPT) + for device in async_get_devices(api.bootstrap, DEVICES_THAT_ADOPT) ) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 8020d5e8aab..d59ee59b760 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -110,10 +110,10 @@ class ProtectCamera(ProtectDeviceEntity, Camera): super().__init__(data, camera) if self._secure: - self._attr_unique_id = f"{self.device.id}_{self.channel.id}" + self._attr_unique_id = f"{self.device.mac}_{self.channel.id}" self._attr_name = f"{self.device.name} {self.channel.name}" else: - self._attr_unique_id = f"{self.device.id}_{self.channel.id}_insecure" + self._attr_unique_id = f"{self.device.mac}_{self.channel.id}_insecure" self._attr_name = f"{self.device.name} {self.channel.name} Insecure" # only the default (first) channel is enabled by default self._attr_entity_registry_enabled_default = is_default and secure diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index bcc1e561e99..1e9729f7930 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -21,7 +21,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.event import async_track_time_interval from .const import CONF_DISABLE_RTSP, DEVICES_THAT_ADOPT, DEVICES_WITH_ENTITIES, DOMAIN -from .utils import async_get_adoptable_devices_by_type, async_get_devices +from .utils import async_get_devices, async_get_devices_by_type _LOGGER = logging.getLogger(__name__) @@ -70,8 +70,8 @@ class ProtectData: ) -> Generator[ProtectAdoptableDeviceModel, None, None]: """Get all devices matching types.""" for device_type in device_types: - yield from async_get_adoptable_devices_by_type( - self.api, device_type + yield from async_get_devices_by_type( + self.api.bootstrap, device_type ).values() async def async_setup(self) -> None: @@ -153,7 +153,7 @@ class ProtectData: return self.async_signal_device_id_update(self.api.bootstrap.nvr.id) - for device in async_get_devices(self.api, DEVICES_THAT_ADOPT): + for device in async_get_devices(self.api.bootstrap, DEVICES_THAT_ADOPT): self.async_signal_device_id_update(device.id) @callback diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 2911a861535..6de0a4c57cb 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -26,7 +26,7 @@ from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from .const import ATTR_EVENT_SCORE, DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN from .data import ProtectData from .models import ProtectRequiredKeysMixin -from .utils import async_get_adoptable_devices_by_type, get_nested_attr +from .utils import async_device_by_id, get_nested_attr _LOGGER = logging.getLogger(__name__) @@ -117,11 +117,11 @@ class ProtectDeviceEntity(Entity): self.device = device if description is None: - self._attr_unique_id = f"{self.device.id}" + self._attr_unique_id = f"{self.device.mac}" self._attr_name = f"{self.device.name}" else: self.entity_description = description - self._attr_unique_id = f"{self.device.id}_{description.key}" + self._attr_unique_id = f"{self.device.mac}_{description.key}" name = description.name or "" self._attr_name = f"{self.device.name} {name.title()}" @@ -153,10 +153,11 @@ class ProtectDeviceEntity(Entity): """Update Entity object from Protect device.""" if self.data.last_update_success: assert self.device.model - devices = async_get_adoptable_devices_by_type( - self.data.api, self.device.model + device = async_device_by_id( + self.data.api.bootstrap, self.device.id, device_type=self.device.model ) - self.device = devices[self.device.id] + assert device is not None + self.device = device is_connected = ( self.data.last_update_success and self.device.state == StateType.CONNECTED diff --git a/homeassistant/components/unifiprotect/migrate.py b/homeassistant/components/unifiprotect/migrate.py index dcba0b504c9..307020caa5c 100644 --- a/homeassistant/components/unifiprotect/migrate.py +++ b/homeassistant/components/unifiprotect/migrate.py @@ -3,14 +3,18 @@ from __future__ import annotations import logging +from aiohttp.client_exceptions import ServerDisconnectedError from pyunifiprotect import ProtectApiClient +from pyunifiprotect.data import NVR, Bootstrap, ProtectAdoptableDeviceModel +from pyunifiprotect.exceptions import ClientError from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry as er -from .const import DEVICES_THAT_ADOPT +from .utils import async_device_by_id _LOGGER = logging.getLogger(__name__) @@ -24,6 +28,21 @@ async def async_migrate_data( await async_migrate_buttons(hass, entry, protect) _LOGGER.debug("Completed Migrate: async_migrate_buttons") + _LOGGER.debug("Start Migrate: async_migrate_device_ids") + await async_migrate_device_ids(hass, entry, protect) + _LOGGER.debug("Completed Migrate: async_migrate_device_ids") + + +async def async_get_bootstrap(protect: ProtectApiClient) -> Bootstrap: + """Get UniFi Protect bootstrap or raise appropriate HA error.""" + + try: + bootstrap = await protect.get_bootstrap() + except (TimeoutError, ClientError, ServerDisconnectedError) as err: + raise ConfigEntryNotReady from err + + return bootstrap + async def async_migrate_buttons( hass: HomeAssistant, entry: ConfigEntry, protect: ProtectApiClient @@ -47,16 +66,10 @@ async def async_migrate_buttons( _LOGGER.debug("No button entities need migration") return - bootstrap = await protect.get_bootstrap() + bootstrap = await async_get_bootstrap(protect) count = 0 for button in to_migrate: - device = None - for model in DEVICES_THAT_ADOPT: - attr = f"{model.value}s" - device = getattr(bootstrap, attr).get(button.unique_id) - if device is not None: - break - + device = async_device_by_id(bootstrap, button.unique_id) if device is None: continue @@ -81,3 +94,68 @@ async def async_migrate_buttons( if count < len(to_migrate): _LOGGER.warning("Failed to migate %s reboot buttons", len(to_migrate) - count) + + +async def async_migrate_device_ids( + hass: HomeAssistant, entry: ConfigEntry, protect: ProtectApiClient +) -> None: + """ + Migrate unique IDs from {device_id}_{name} format to {mac}_{name} format. + + This makes devices persist better with in HA. Anything a device is unadopted/readopted or + the Protect instance has to rebuild the disk array, the device IDs of Protect devices + can change. This causes a ton of orphaned entities and loss of historical data. MAC + addresses are the one persistent identifier a device has that does not change. + + Added in 2022.7.0. + """ + + registry = er.async_get(hass) + to_migrate = [] + for entity in er.async_entries_for_config_entry(registry, entry.entry_id): + parts = entity.unique_id.split("_") + # device ID = 24 characters, MAC = 12 + if len(parts[0]) == 24: + _LOGGER.debug("Entity %s needs migration", entity.entity_id) + to_migrate.append(entity) + + if len(to_migrate) == 0: + _LOGGER.debug("No entities need migration to MAC address ID") + return + + bootstrap = await async_get_bootstrap(protect) + count = 0 + for entity in to_migrate: + parts = entity.unique_id.split("_") + if parts[0] == bootstrap.nvr.id: + device: NVR | ProtectAdoptableDeviceModel | None = bootstrap.nvr + else: + device = async_device_by_id(bootstrap, parts[0]) + + if device is None: + continue + + new_unique_id = device.mac + if len(parts) > 1: + new_unique_id = f"{device.mac}_{'_'.join(parts[1:])}" + _LOGGER.debug( + "Migrating entity %s (old unique_id: %s, new unique_id: %s)", + entity.entity_id, + entity.unique_id, + new_unique_id, + ) + try: + registry.async_update_entity(entity.entity_id, new_unique_id=new_unique_id) + except ValueError as err: + print(err) + _LOGGER.warning( + "Could not migrate entity %s (old unique_id: %s, new unique_id: %s)", + entity.entity_id, + entity.unique_id, + new_unique_id, + ) + else: + count += 1 + + if count < len(to_migrate): + _LOGGER.warning("Failed to migrate %s entities", len(to_migrate) - count) diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 828aa9ecfd7..3b7b3db026f 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -3,10 +3,11 @@ from __future__ import annotations import asyncio import functools -from typing import Any +from typing import Any, cast from pydantic import ValidationError from pyunifiprotect.api import ProtectApiClient +from pyunifiprotect.data import Chime from pyunifiprotect.exceptions import BadRequest import voluptuous as vol @@ -122,8 +123,8 @@ async def set_default_doorbell_text(hass: HomeAssistant, call: ServiceCall) -> N @callback -def _async_unique_id_to_ufp_device_id(unique_id: str) -> str: - """Extract the UFP device id from the registry entry unique id.""" +def _async_unique_id_to_mac(unique_id: str) -> str: + """Extract the MAC address from the registry entry unique id.""" return unique_id.split("_")[0] @@ -136,10 +137,12 @@ async def set_chime_paired_doorbells(hass: HomeAssistant, call: ServiceCall) -> chime_button = entity_registry.async_get(entity_id) assert chime_button is not None assert chime_button.device_id is not None - chime_ufp_device_id = _async_unique_id_to_ufp_device_id(chime_button.unique_id) + chime_mac = _async_unique_id_to_mac(chime_button.unique_id) instance = _async_get_ufp_instance(hass, chime_button.device_id) - chime = instance.bootstrap.chimes[chime_ufp_device_id] + chime = instance.bootstrap.get_device_from_mac(chime_mac) + chime = cast(Chime, chime) + assert chime is not None call.data = ReadOnlyDict(call.data.get("doorbells") or {}) doorbell_refs = async_extract_referenced_entity_ids(hass, call) @@ -154,10 +157,9 @@ async def set_chime_paired_doorbells(hass: HomeAssistant, call: ServiceCall) -> != BinarySensorDeviceClass.OCCUPANCY ): continue - doorbell_ufp_device_id = _async_unique_id_to_ufp_device_id( - doorbell_sensor.unique_id - ) - camera = instance.bootstrap.cameras[doorbell_ufp_device_id] + doorbell_mac = _async_unique_id_to_mac(doorbell_sensor.unique_id) + camera = instance.bootstrap.get_device_from_mac(doorbell_mac) + assert camera is not None doorbell_ids.add(camera.id) chime.camera_ids = sorted(doorbell_ids) await chime.save_device() diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index fffe987db0f..b57753e15d4 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -7,12 +7,15 @@ from enum import Enum import socket from typing import Any -from pyunifiprotect import ProtectApiClient -from pyunifiprotect.data.base import ProtectAdoptableDeviceModel, ProtectDeviceModel +from pyunifiprotect.data import ( + Bootstrap, + ProtectAdoptableDeviceModel, + ProtectDeviceModel, +) from homeassistant.core import HomeAssistant, callback -from .const import ModelType +from .const import DEVICES_THAT_ADOPT, ModelType def get_nested_attr(obj: Any, attr: str) -> Any: @@ -59,33 +62,45 @@ async def _async_resolve(hass: HomeAssistant, host: str) -> str | None: return None +@callback def async_get_devices_by_type( - api: ProtectApiClient, device_type: ModelType -) -> dict[str, ProtectDeviceModel]: - """Get devices by type.""" - devices: dict[str, ProtectDeviceModel] = getattr( - api.bootstrap, f"{device_type.value}s" - ) - return devices - - -def async_get_adoptable_devices_by_type( - api: ProtectApiClient, device_type: ModelType + bootstrap: Bootstrap, device_type: ModelType ) -> dict[str, ProtectAdoptableDeviceModel]: - """Get adoptable devices by type.""" + """Get devices by type.""" + devices: dict[str, ProtectAdoptableDeviceModel] = getattr( - api.bootstrap, f"{device_type.value}s" + bootstrap, f"{device_type.value}s" ) return devices +@callback +def async_device_by_id( + bootstrap: Bootstrap, + device_id: str, + device_type: ModelType | None = None, +) -> ProtectAdoptableDeviceModel | None: + """Get devices by type.""" + + device_types = DEVICES_THAT_ADOPT + if device_type is not None: + device_types = {device_type} + + device = None + for model in device_types: + device = async_get_devices_by_type(bootstrap, model).get(device_id) + if device is not None: + break + return device + + @callback def async_get_devices( - api: ProtectApiClient, model_type: Iterable[ModelType] + bootstrap: Bootstrap, model_type: Iterable[ModelType] ) -> Generator[ProtectDeviceModel, None, None]: """Return all device by type.""" return ( device for device_type in model_type - for device in async_get_devices_by_type(api, device_type).values() + for device in async_get_devices_by_type(bootstrap, device_type).values() ) diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index 3986e4cd5a3..9892bcc3ec6 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -23,6 +23,7 @@ from pyunifiprotect.data import ( Viewer, WSSubscriptionMessage, ) +from pyunifiprotect.test_util.anonymize import random_hex from homeassistant.components.unifiprotect.const import DOMAIN from homeassistant.const import Platform @@ -80,6 +81,26 @@ class MockBootstrap: "chimes": [c.unifi_dict() for c in self.chimes.values()], } + def get_device_from_mac(self, mac: str) -> ProtectAdoptableDeviceModel | None: + """Return device for MAC address.""" + + mac = mac.lower().replace(":", "").replace("-", "").replace("_", "") + + all_devices = ( + self.cameras.values(), + self.lights.values(), + self.sensors.values(), + self.viewers.values(), + self.liveviews.values(), + self.doorlocks.values(), + self.chimes.values(), + ) + for devices in all_devices: + for device in devices: + if device.mac.lower() == mac: + return device + return None + @dataclass class MockEntityFixture: @@ -301,7 +322,19 @@ def ids_from_device_description( description.name.lower().replace(":", "").replace(" ", "_").replace("-", "_") ) - unique_id = f"{device.id}_{description.key}" + unique_id = f"{device.mac}_{description.key}" entity_id = f"{platform.value}.{entity_name}_{description_entity_name}" return unique_id, entity_id + + +def generate_random_ids() -> tuple[str, str]: + """Generate random IDs for device.""" + + return random_hex(24).upper(), random_hex(12).upper() + + +def regenerate_device_ids(device: ProtectAdoptableDeviceModel) -> None: + """Regenerate the IDs on UFP device.""" + + device.id, device.mac = generate_random_ids() diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 834f8634ee1..8fbaf61aca1 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -366,7 +366,6 @@ async def test_binary_sensor_setup_sensor_none( state = hass.states.get(entity_id) assert state - print(entity_id) assert state.state == expected[index] assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION diff --git a/tests/components/unifiprotect/test_button.py b/tests/components/unifiprotect/test_button.py index f3b76cb7abb..5b7122f6227 100644 --- a/tests/components/unifiprotect/test_button.py +++ b/tests/components/unifiprotect/test_button.py @@ -46,7 +46,7 @@ async def test_reboot_button( mock_entry.api.reboot_device = AsyncMock() - unique_id = f"{chime.id}_reboot" + unique_id = f"{chime.mac}_reboot" entity_id = "button.test_chime_reboot_device" entity_registry = er.async_get(hass) @@ -75,7 +75,7 @@ async def test_chime_button( mock_entry.api.play_speaker = AsyncMock() - unique_id = f"{chime.id}_play" + unique_id = f"{chime.mac}_play" entity_id = "button.test_chime_play_chime" entity_registry = er.async_get(hass) diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 538b3bf7652..2f8d2607da0 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -38,6 +38,7 @@ from .conftest import ( MockEntityFixture, assert_entity_counts, enable_entity, + regenerate_device_ids, time_changed, ) @@ -124,7 +125,7 @@ def validate_default_camera_entity( channel = camera_obj.channels[channel_id] entity_name = f"{camera_obj.name} {channel.name}" - unique_id = f"{camera_obj.id}_{channel.id}" + unique_id = f"{camera_obj.mac}_{channel.id}" entity_id = f"camera.{entity_name.replace(' ', '_').lower()}" entity_registry = er.async_get(hass) @@ -146,7 +147,7 @@ def validate_rtsps_camera_entity( channel = camera_obj.channels[channel_id] entity_name = f"{camera_obj.name} {channel.name}" - unique_id = f"{camera_obj.id}_{channel.id}" + unique_id = f"{camera_obj.mac}_{channel.id}" entity_id = f"camera.{entity_name.replace(' ', '_').lower()}" entity_registry = er.async_get(hass) @@ -168,7 +169,7 @@ def validate_rtsp_camera_entity( channel = camera_obj.channels[channel_id] entity_name = f"{camera_obj.name} {channel.name} Insecure" - unique_id = f"{camera_obj.id}_{channel.id}_insecure" + unique_id = f"{camera_obj.mac}_{channel.id}_insecure" entity_id = f"camera.{entity_name.replace(' ', '_').lower()}" entity_registry = er.async_get(hass) @@ -251,12 +252,12 @@ async def test_basic_setup( camera_high_only.channels[1]._api = mock_entry.api camera_high_only.channels[2]._api = mock_entry.api camera_high_only.name = "Test Camera 1" - camera_high_only.id = "test_high" camera_high_only.channels[0].is_rtsp_enabled = True camera_high_only.channels[0].name = "High" camera_high_only.channels[0].rtsp_alias = "test_high_alias" camera_high_only.channels[1].is_rtsp_enabled = False camera_high_only.channels[2].is_rtsp_enabled = False + regenerate_device_ids(camera_high_only) camera_medium_only = mock_camera.copy(deep=True) camera_medium_only._api = mock_entry.api @@ -264,12 +265,12 @@ async def test_basic_setup( camera_medium_only.channels[1]._api = mock_entry.api camera_medium_only.channels[2]._api = mock_entry.api camera_medium_only.name = "Test Camera 2" - camera_medium_only.id = "test_medium" camera_medium_only.channels[0].is_rtsp_enabled = False camera_medium_only.channels[1].is_rtsp_enabled = True camera_medium_only.channels[1].name = "Medium" camera_medium_only.channels[1].rtsp_alias = "test_medium_alias" camera_medium_only.channels[2].is_rtsp_enabled = False + regenerate_device_ids(camera_medium_only) camera_all_channels = mock_camera.copy(deep=True) camera_all_channels._api = mock_entry.api @@ -277,7 +278,6 @@ async def test_basic_setup( camera_all_channels.channels[1]._api = mock_entry.api camera_all_channels.channels[2]._api = mock_entry.api camera_all_channels.name = "Test Camera 3" - camera_all_channels.id = "test_all" camera_all_channels.channels[0].is_rtsp_enabled = True camera_all_channels.channels[0].name = "High" camera_all_channels.channels[0].rtsp_alias = "test_high_alias" @@ -287,6 +287,7 @@ async def test_basic_setup( camera_all_channels.channels[2].is_rtsp_enabled = True camera_all_channels.channels[2].name = "Low" camera_all_channels.channels[2].rtsp_alias = "test_low_alias" + regenerate_device_ids(camera_all_channels) camera_no_channels = mock_camera.copy(deep=True) camera_no_channels._api = mock_entry.api @@ -294,11 +295,11 @@ async def test_basic_setup( camera_no_channels.channels[1]._api = mock_entry.api camera_no_channels.channels[2]._api = mock_entry.api camera_no_channels.name = "Test Camera 4" - camera_no_channels.id = "test_none" camera_no_channels.channels[0].is_rtsp_enabled = False camera_no_channels.channels[0].name = "High" camera_no_channels.channels[1].is_rtsp_enabled = False camera_no_channels.channels[2].is_rtsp_enabled = False + regenerate_device_ids(camera_no_channels) camera_package = mock_camera.copy(deep=True) camera_package._api = mock_entry.api @@ -306,12 +307,12 @@ async def test_basic_setup( camera_package.channels[1]._api = mock_entry.api camera_package.channels[2]._api = mock_entry.api camera_package.name = "Test Camera 5" - camera_package.id = "test_package" camera_package.channels[0].is_rtsp_enabled = True camera_package.channels[0].name = "High" camera_package.channels[0].rtsp_alias = "test_high_alias" camera_package.channels[1].is_rtsp_enabled = False camera_package.channels[2].is_rtsp_enabled = False + regenerate_device_ids(camera_package) package_channel = camera_package.channels[0].copy(deep=True) package_channel.is_rtsp_enabled = False package_channel.name = "Package Camera" diff --git a/tests/components/unifiprotect/test_diagnostics.py b/tests/components/unifiprotect/test_diagnostics.py index b58e164e913..2e7f8c0e4b4 100644 --- a/tests/components/unifiprotect/test_diagnostics.py +++ b/tests/components/unifiprotect/test_diagnostics.py @@ -4,7 +4,7 @@ from pyunifiprotect.data import NVR, Light from homeassistant.core import HomeAssistant -from .conftest import MockEntityFixture +from .conftest import MockEntityFixture, regenerate_device_ids from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -17,7 +17,7 @@ async def test_diagnostics( light1 = mock_light.copy() light1._api = mock_entry.api light1.name = "Test Light 1" - light1.id = "lightid1" + regenerate_device_ids(light1) mock_entry.api.bootstrap.lights = { light1.id: light1, diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 68f171b52bf..23dfa12fc97 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -16,7 +16,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from . import _patch_discovery -from .conftest import MockBootstrap, MockEntityFixture +from .conftest import MockBootstrap, MockEntityFixture, regenerate_device_ids from tests.common import MockConfigEntry @@ -212,8 +212,7 @@ async def test_device_remove_devices( light1 = mock_light.copy() light1._api = mock_entry.api light1.name = "Test Light 1" - light1.id = "lightid1" - light1.mac = "AABBCCDDEEFF" + regenerate_device_ids(light1) mock_entry.api.bootstrap.lights = { light1.id: light1, diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py index 8f4dc4f8fcf..a3686fdfbd9 100644 --- a/tests/components/unifiprotect/test_light.py +++ b/tests/components/unifiprotect/test_light.py @@ -57,7 +57,7 @@ async def test_light_setup( ): """Test light entity setup.""" - unique_id = light[0].id + unique_id = light[0].mac entity_id = light[1] entity_registry = er.async_get(hass) diff --git a/tests/components/unifiprotect/test_lock.py b/tests/components/unifiprotect/test_lock.py index 0a02fcb22a4..abcea4ec04e 100644 --- a/tests/components/unifiprotect/test_lock.py +++ b/tests/components/unifiprotect/test_lock.py @@ -60,7 +60,7 @@ async def test_lock_setup( ): """Test lock entity setup.""" - unique_id = f"{doorlock[0].id}_lock" + unique_id = f"{doorlock[0].mac}_lock" entity_id = doorlock[1] entity_registry = er.async_get(hass) diff --git a/tests/components/unifiprotect/test_media_player.py b/tests/components/unifiprotect/test_media_player.py index c4586eb7880..d6404ee3fe5 100644 --- a/tests/components/unifiprotect/test_media_player.py +++ b/tests/components/unifiprotect/test_media_player.py @@ -66,7 +66,7 @@ async def test_media_player_setup( ): """Test media_player entity setup.""" - unique_id = f"{camera[0].id}_speaker" + unique_id = f"{camera[0].mac}_speaker" entity_id = camera[1] entity_registry = er.async_get(hass) diff --git a/tests/components/unifiprotect/test_migrate.py b/tests/components/unifiprotect/test_migrate.py index 756672bcbca..b62aa9d7757 100644 --- a/tests/components/unifiprotect/test_migrate.py +++ b/tests/components/unifiprotect/test_migrate.py @@ -12,7 +12,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockEntityFixture +from .conftest import MockEntityFixture, generate_random_ids, regenerate_device_ids async def test_migrate_reboot_button( @@ -23,12 +23,13 @@ async def test_migrate_reboot_button( light1 = mock_light.copy() light1._api = mock_entry.api light1.name = "Test Light 1" - light1.id = "lightid1" + regenerate_device_ids(light1) light2 = mock_light.copy() light2._api = mock_entry.api light2.name = "Test Light 2" - light2.id = "lightid2" + regenerate_device_ids(light2) + mock_entry.api.bootstrap.lights = { light1.id: light1, light2.id: light2, @@ -42,7 +43,7 @@ async def test_migrate_reboot_button( registry.async_get_or_create( Platform.BUTTON, DOMAIN, - f"{light2.id}_reboot", + f"{light2.mac}_reboot", config_entry=mock_entry.entry, ) @@ -59,20 +60,21 @@ async def test_migrate_reboot_button( ): if entity.domain == Platform.BUTTON.value: buttons.append(entity) - print(entity.entity_id) assert len(buttons) == 2 assert registry.async_get(f"{Platform.BUTTON}.test_light_1_reboot_device") is None assert registry.async_get(f"{Platform.BUTTON}.test_light_1_reboot_device_2") is None - light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_lightid1") + light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_{light1.id.lower()}") assert light is not None - assert light.unique_id == f"{light1.id}_reboot" + assert light.unique_id == f"{light1.mac}_reboot" assert registry.async_get(f"{Platform.BUTTON}.test_light_2_reboot_device") is None assert registry.async_get(f"{Platform.BUTTON}.test_light_2_reboot_device_2") is None - light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_lightid2_reboot") + light = registry.async_get( + f"{Platform.BUTTON}.unifiprotect_{light2.mac.lower()}_reboot" + ) assert light is not None - assert light.unique_id == f"{light2.id}_reboot" + assert light.unique_id == f"{light2.mac}_reboot" async def test_migrate_reboot_button_no_device( @@ -83,7 +85,9 @@ async def test_migrate_reboot_button_no_device( light1 = mock_light.copy() light1._api = mock_entry.api light1.name = "Test Light 1" - light1.id = "lightid1" + regenerate_device_ids(light1) + + light2_id, _ = generate_random_ids() mock_entry.api.bootstrap.lights = { light1.id: light1, @@ -92,7 +96,7 @@ async def test_migrate_reboot_button_no_device( registry = er.async_get(hass) registry.async_get_or_create( - Platform.BUTTON, DOMAIN, "lightid2", config_entry=mock_entry.entry + Platform.BUTTON, DOMAIN, light2_id, config_entry=mock_entry.entry ) await hass.config_entries.async_setup(mock_entry.entry.entry_id) @@ -110,9 +114,9 @@ async def test_migrate_reboot_button_no_device( buttons.append(entity) assert len(buttons) == 2 - light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_lightid2") + light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_{light2_id.lower()}") assert light is not None - assert light.unique_id == "lightid2" + assert light.unique_id == light2_id async def test_migrate_reboot_button_fail( @@ -123,7 +127,7 @@ async def test_migrate_reboot_button_fail( light1 = mock_light.copy() light1._api = mock_entry.api light1.name = "Test Light 1" - light1.id = "lightid1" + regenerate_device_ids(light1) mock_entry.api.bootstrap.lights = { light1.id: light1, @@ -155,4 +159,47 @@ async def test_migrate_reboot_button_fail( light = registry.async_get(f"{Platform.BUTTON}.test_light_1") assert light is not None - assert light.unique_id == f"{light1.id}" + assert light.unique_id == f"{light1.mac}" + + +async def test_migrate_device_mac_button_fail( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light +): + """Test migrating unique ID to MAC format.""" + + light1 = mock_light.copy() + light1._api = mock_entry.api + light1.name = "Test Light 1" + regenerate_device_ids(light1) + + mock_entry.api.bootstrap.lights = { + light1.id: light1, + } + mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) + + registry = er.async_get(hass) + registry.async_get_or_create( + Platform.BUTTON, + DOMAIN, + f"{light1.id}_reboot", + config_entry=mock_entry.entry, + suggested_object_id=light1.name, + ) + registry.async_get_or_create( + Platform.BUTTON, + DOMAIN, + f"{light1.mac}_reboot", + config_entry=mock_entry.entry, + suggested_object_id=light1.name, + ) + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert mock_entry.entry.state == ConfigEntryState.LOADED + assert mock_entry.api.update.called + assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac + + light = registry.async_get(f"{Platform.BUTTON}.test_light_1") + assert light is not None + assert light.unique_id == f"{light1.id}_reboot" diff --git a/tests/components/unifiprotect/test_services.py b/tests/components/unifiprotect/test_services.py index 22f7fdd1a6c..2ad3821cc40 100644 --- a/tests/components/unifiprotect/test_services.py +++ b/tests/components/unifiprotect/test_services.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from .conftest import MockEntityFixture +from .conftest import MockEntityFixture, regenerate_device_ids @pytest.fixture(name="device") @@ -165,22 +165,22 @@ async def test_set_chime_paired_doorbells( } camera1 = mock_camera.copy() - camera1.id = "cameraid1" camera1.name = "Test Camera 1" camera1._api = mock_entry.api camera1.channels[0]._api = mock_entry.api camera1.channels[1]._api = mock_entry.api camera1.channels[2]._api = mock_entry.api camera1.feature_flags.has_chime = True + regenerate_device_ids(camera1) camera2 = mock_camera.copy() - camera2.id = "cameraid2" camera2.name = "Test Camera 2" camera2._api = mock_entry.api camera2.channels[0]._api = mock_entry.api camera2.channels[1]._api = mock_entry.api camera2.channels[2]._api = mock_entry.api camera2.feature_flags.has_chime = True + regenerate_device_ids(camera2) mock_entry.api.bootstrap.cameras = { camera1.id: camera1, @@ -210,5 +210,5 @@ async def test_set_chime_paired_doorbells( ) mock_entry.api.update_device.assert_called_once_with( - ModelType.CHIME, mock_chime.id, {"cameraIds": [camera1.id, camera2.id]} + ModelType.CHIME, mock_chime.id, {"cameraIds": sorted([camera1.id, camera2.id])} ) diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index bc0c8387c29..8ca1ef9b533 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -238,7 +238,7 @@ async def test_switch_setup_light( description = LIGHT_SWITCHES[0] - unique_id = f"{light.id}_{description.key}" + unique_id = f"{light.mac}_{description.key}" entity_id = f"switch.test_light_{description.name.lower().replace(' ', '_')}" entity = entity_registry.async_get(entity_id) @@ -282,7 +282,7 @@ async def test_switch_setup_camera_all( description_entity_name = ( description.name.lower().replace(":", "").replace(" ", "_") ) - unique_id = f"{camera.id}_{description.key}" + unique_id = f"{camera.mac}_{description.key}" entity_id = f"switch.test_camera_{description_entity_name}" entity = entity_registry.async_get(entity_id) @@ -329,7 +329,7 @@ async def test_switch_setup_camera_none( description_entity_name = ( description.name.lower().replace(":", "").replace(" ", "_") ) - unique_id = f"{camera_none.id}_{description.key}" + unique_id = f"{camera_none.mac}_{description.key}" entity_id = f"switch.test_camera_{description_entity_name}" entity = entity_registry.async_get(entity_id)