Update UniFi Protect to use MAC address for unique ID (#73508)

This commit is contained in:
Christopher Bailey 2022-06-19 10:22:33 -04:00 committed by GitHub
parent 7714183118
commit b19b6ec6ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 266 additions and 91 deletions

View file

@ -128,5 +128,5 @@ async def async_remove_config_entry_device(
assert api is not None assert api is not None
return api.bootstrap.nvr.mac not in unifi_macs and not any( return api.bootstrap.nvr.mac not in unifi_macs and not any(
device.mac in unifi_macs 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)
) )

View file

@ -110,10 +110,10 @@ class ProtectCamera(ProtectDeviceEntity, Camera):
super().__init__(data, camera) super().__init__(data, camera)
if self._secure: 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}" self._attr_name = f"{self.device.name} {self.channel.name}"
else: 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" self._attr_name = f"{self.device.name} {self.channel.name} Insecure"
# only the default (first) channel is enabled by default # only the default (first) channel is enabled by default
self._attr_entity_registry_enabled_default = is_default and secure self._attr_entity_registry_enabled_default = is_default and secure

View file

@ -21,7 +21,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.event import async_track_time_interval
from .const import CONF_DISABLE_RTSP, DEVICES_THAT_ADOPT, DEVICES_WITH_ENTITIES, DOMAIN 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__) _LOGGER = logging.getLogger(__name__)
@ -70,8 +70,8 @@ class ProtectData:
) -> Generator[ProtectAdoptableDeviceModel, None, None]: ) -> Generator[ProtectAdoptableDeviceModel, None, None]:
"""Get all devices matching types.""" """Get all devices matching types."""
for device_type in device_types: for device_type in device_types:
yield from async_get_adoptable_devices_by_type( yield from async_get_devices_by_type(
self.api, device_type self.api.bootstrap, device_type
).values() ).values()
async def async_setup(self) -> None: async def async_setup(self) -> None:
@ -153,7 +153,7 @@ class ProtectData:
return return
self.async_signal_device_id_update(self.api.bootstrap.nvr.id) 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) self.async_signal_device_id_update(device.id)
@callback @callback

View file

@ -26,7 +26,7 @@ from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription
from .const import ATTR_EVENT_SCORE, DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN from .const import ATTR_EVENT_SCORE, DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN
from .data import ProtectData from .data import ProtectData
from .models import ProtectRequiredKeysMixin 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__) _LOGGER = logging.getLogger(__name__)
@ -117,11 +117,11 @@ class ProtectDeviceEntity(Entity):
self.device = device self.device = device
if description is None: 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}" self._attr_name = f"{self.device.name}"
else: else:
self.entity_description = description 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 "" name = description.name or ""
self._attr_name = f"{self.device.name} {name.title()}" self._attr_name = f"{self.device.name} {name.title()}"
@ -153,10 +153,11 @@ class ProtectDeviceEntity(Entity):
"""Update Entity object from Protect device.""" """Update Entity object from Protect device."""
if self.data.last_update_success: if self.data.last_update_success:
assert self.device.model assert self.device.model
devices = async_get_adoptable_devices_by_type( device = async_device_by_id(
self.data.api, self.device.model 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 = ( is_connected = (
self.data.last_update_success and self.device.state == StateType.CONNECTED self.data.last_update_success and self.device.state == StateType.CONNECTED

View file

@ -3,14 +3,18 @@ from __future__ import annotations
import logging import logging
from aiohttp.client_exceptions import ServerDisconnectedError
from pyunifiprotect import ProtectApiClient from pyunifiprotect import ProtectApiClient
from pyunifiprotect.data import NVR, Bootstrap, ProtectAdoptableDeviceModel
from pyunifiprotect.exceptions import ClientError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er 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__) _LOGGER = logging.getLogger(__name__)
@ -24,6 +28,21 @@ async def async_migrate_data(
await async_migrate_buttons(hass, entry, protect) await async_migrate_buttons(hass, entry, protect)
_LOGGER.debug("Completed Migrate: async_migrate_buttons") _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( async def async_migrate_buttons(
hass: HomeAssistant, entry: ConfigEntry, protect: ProtectApiClient hass: HomeAssistant, entry: ConfigEntry, protect: ProtectApiClient
@ -47,16 +66,10 @@ async def async_migrate_buttons(
_LOGGER.debug("No button entities need migration") _LOGGER.debug("No button entities need migration")
return return
bootstrap = await protect.get_bootstrap() bootstrap = await async_get_bootstrap(protect)
count = 0 count = 0
for button in to_migrate: for button in to_migrate:
device = None device = async_device_by_id(bootstrap, button.unique_id)
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
if device is None: if device is None:
continue continue
@ -81,3 +94,68 @@ async def async_migrate_buttons(
if count < len(to_migrate): if count < len(to_migrate):
_LOGGER.warning("Failed to migate %s reboot buttons", len(to_migrate) - count) _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)

View file

@ -3,10 +3,11 @@ from __future__ import annotations
import asyncio import asyncio
import functools import functools
from typing import Any from typing import Any, cast
from pydantic import ValidationError from pydantic import ValidationError
from pyunifiprotect.api import ProtectApiClient from pyunifiprotect.api import ProtectApiClient
from pyunifiprotect.data import Chime
from pyunifiprotect.exceptions import BadRequest from pyunifiprotect.exceptions import BadRequest
import voluptuous as vol import voluptuous as vol
@ -122,8 +123,8 @@ async def set_default_doorbell_text(hass: HomeAssistant, call: ServiceCall) -> N
@callback @callback
def _async_unique_id_to_ufp_device_id(unique_id: str) -> str: def _async_unique_id_to_mac(unique_id: str) -> str:
"""Extract the UFP device id from the registry entry unique id.""" """Extract the MAC address from the registry entry unique id."""
return unique_id.split("_")[0] 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) chime_button = entity_registry.async_get(entity_id)
assert chime_button is not None assert chime_button is not None
assert chime_button.device_id 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) 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 {}) call.data = ReadOnlyDict(call.data.get("doorbells") or {})
doorbell_refs = async_extract_referenced_entity_ids(hass, call) 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 != BinarySensorDeviceClass.OCCUPANCY
): ):
continue continue
doorbell_ufp_device_id = _async_unique_id_to_ufp_device_id( doorbell_mac = _async_unique_id_to_mac(doorbell_sensor.unique_id)
doorbell_sensor.unique_id camera = instance.bootstrap.get_device_from_mac(doorbell_mac)
) assert camera is not None
camera = instance.bootstrap.cameras[doorbell_ufp_device_id]
doorbell_ids.add(camera.id) doorbell_ids.add(camera.id)
chime.camera_ids = sorted(doorbell_ids) chime.camera_ids = sorted(doorbell_ids)
await chime.save_device() await chime.save_device()

View file

@ -7,12 +7,15 @@ from enum import Enum
import socket import socket
from typing import Any from typing import Any
from pyunifiprotect import ProtectApiClient from pyunifiprotect.data import (
from pyunifiprotect.data.base import ProtectAdoptableDeviceModel, ProtectDeviceModel Bootstrap,
ProtectAdoptableDeviceModel,
ProtectDeviceModel,
)
from homeassistant.core import HomeAssistant, callback 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: 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 return None
@callback
def async_get_devices_by_type( def async_get_devices_by_type(
api: ProtectApiClient, device_type: ModelType bootstrap: Bootstrap, 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
) -> dict[str, ProtectAdoptableDeviceModel]: ) -> dict[str, ProtectAdoptableDeviceModel]:
"""Get adoptable devices by type.""" """Get devices by type."""
devices: dict[str, ProtectAdoptableDeviceModel] = getattr( devices: dict[str, ProtectAdoptableDeviceModel] = getattr(
api.bootstrap, f"{device_type.value}s" bootstrap, f"{device_type.value}s"
) )
return devices 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 @callback
def async_get_devices( def async_get_devices(
api: ProtectApiClient, model_type: Iterable[ModelType] bootstrap: Bootstrap, model_type: Iterable[ModelType]
) -> Generator[ProtectDeviceModel, None, None]: ) -> Generator[ProtectDeviceModel, None, None]:
"""Return all device by type.""" """Return all device by type."""
return ( return (
device device
for device_type in model_type 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()
) )

View file

@ -23,6 +23,7 @@ from pyunifiprotect.data import (
Viewer, Viewer,
WSSubscriptionMessage, WSSubscriptionMessage,
) )
from pyunifiprotect.test_util.anonymize import random_hex
from homeassistant.components.unifiprotect.const import DOMAIN from homeassistant.components.unifiprotect.const import DOMAIN
from homeassistant.const import Platform from homeassistant.const import Platform
@ -80,6 +81,26 @@ class MockBootstrap:
"chimes": [c.unifi_dict() for c in self.chimes.values()], "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 @dataclass
class MockEntityFixture: class MockEntityFixture:
@ -301,7 +322,19 @@ def ids_from_device_description(
description.name.lower().replace(":", "").replace(" ", "_").replace("-", "_") 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}" entity_id = f"{platform.value}.{entity_name}_{description_entity_name}"
return unique_id, entity_id 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()

View file

@ -366,7 +366,6 @@ async def test_binary_sensor_setup_sensor_none(
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state assert state
print(entity_id)
assert state.state == expected[index] assert state.state == expected[index]
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION

View file

@ -46,7 +46,7 @@ async def test_reboot_button(
mock_entry.api.reboot_device = AsyncMock() 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_id = "button.test_chime_reboot_device"
entity_registry = er.async_get(hass) entity_registry = er.async_get(hass)
@ -75,7 +75,7 @@ async def test_chime_button(
mock_entry.api.play_speaker = AsyncMock() 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_id = "button.test_chime_play_chime"
entity_registry = er.async_get(hass) entity_registry = er.async_get(hass)

View file

@ -38,6 +38,7 @@ from .conftest import (
MockEntityFixture, MockEntityFixture,
assert_entity_counts, assert_entity_counts,
enable_entity, enable_entity,
regenerate_device_ids,
time_changed, time_changed,
) )
@ -124,7 +125,7 @@ def validate_default_camera_entity(
channel = camera_obj.channels[channel_id] channel = camera_obj.channels[channel_id]
entity_name = f"{camera_obj.name} {channel.name}" 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_id = f"camera.{entity_name.replace(' ', '_').lower()}"
entity_registry = er.async_get(hass) entity_registry = er.async_get(hass)
@ -146,7 +147,7 @@ def validate_rtsps_camera_entity(
channel = camera_obj.channels[channel_id] channel = camera_obj.channels[channel_id]
entity_name = f"{camera_obj.name} {channel.name}" 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_id = f"camera.{entity_name.replace(' ', '_').lower()}"
entity_registry = er.async_get(hass) entity_registry = er.async_get(hass)
@ -168,7 +169,7 @@ def validate_rtsp_camera_entity(
channel = camera_obj.channels[channel_id] channel = camera_obj.channels[channel_id]
entity_name = f"{camera_obj.name} {channel.name} Insecure" 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_id = f"camera.{entity_name.replace(' ', '_').lower()}"
entity_registry = er.async_get(hass) 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[1]._api = mock_entry.api
camera_high_only.channels[2]._api = mock_entry.api camera_high_only.channels[2]._api = mock_entry.api
camera_high_only.name = "Test Camera 1" 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].is_rtsp_enabled = True
camera_high_only.channels[0].name = "High" camera_high_only.channels[0].name = "High"
camera_high_only.channels[0].rtsp_alias = "test_high_alias" camera_high_only.channels[0].rtsp_alias = "test_high_alias"
camera_high_only.channels[1].is_rtsp_enabled = False camera_high_only.channels[1].is_rtsp_enabled = False
camera_high_only.channels[2].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 = mock_camera.copy(deep=True)
camera_medium_only._api = mock_entry.api 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[1]._api = mock_entry.api
camera_medium_only.channels[2]._api = mock_entry.api camera_medium_only.channels[2]._api = mock_entry.api
camera_medium_only.name = "Test Camera 2" 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[0].is_rtsp_enabled = False
camera_medium_only.channels[1].is_rtsp_enabled = True camera_medium_only.channels[1].is_rtsp_enabled = True
camera_medium_only.channels[1].name = "Medium" camera_medium_only.channels[1].name = "Medium"
camera_medium_only.channels[1].rtsp_alias = "test_medium_alias" camera_medium_only.channels[1].rtsp_alias = "test_medium_alias"
camera_medium_only.channels[2].is_rtsp_enabled = False 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 = mock_camera.copy(deep=True)
camera_all_channels._api = mock_entry.api 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[1]._api = mock_entry.api
camera_all_channels.channels[2]._api = mock_entry.api camera_all_channels.channels[2]._api = mock_entry.api
camera_all_channels.name = "Test Camera 3" 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].is_rtsp_enabled = True
camera_all_channels.channels[0].name = "High" camera_all_channels.channels[0].name = "High"
camera_all_channels.channels[0].rtsp_alias = "test_high_alias" 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].is_rtsp_enabled = True
camera_all_channels.channels[2].name = "Low" camera_all_channels.channels[2].name = "Low"
camera_all_channels.channels[2].rtsp_alias = "test_low_alias" 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 = mock_camera.copy(deep=True)
camera_no_channels._api = mock_entry.api 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[1]._api = mock_entry.api
camera_no_channels.channels[2]._api = mock_entry.api camera_no_channels.channels[2]._api = mock_entry.api
camera_no_channels.name = "Test Camera 4" 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].is_rtsp_enabled = False
camera_no_channels.channels[0].name = "High" camera_no_channels.channels[0].name = "High"
camera_no_channels.channels[1].is_rtsp_enabled = False camera_no_channels.channels[1].is_rtsp_enabled = False
camera_no_channels.channels[2].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 = mock_camera.copy(deep=True)
camera_package._api = mock_entry.api 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[1]._api = mock_entry.api
camera_package.channels[2]._api = mock_entry.api camera_package.channels[2]._api = mock_entry.api
camera_package.name = "Test Camera 5" camera_package.name = "Test Camera 5"
camera_package.id = "test_package"
camera_package.channels[0].is_rtsp_enabled = True camera_package.channels[0].is_rtsp_enabled = True
camera_package.channels[0].name = "High" camera_package.channels[0].name = "High"
camera_package.channels[0].rtsp_alias = "test_high_alias" camera_package.channels[0].rtsp_alias = "test_high_alias"
camera_package.channels[1].is_rtsp_enabled = False camera_package.channels[1].is_rtsp_enabled = False
camera_package.channels[2].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 = camera_package.channels[0].copy(deep=True)
package_channel.is_rtsp_enabled = False package_channel.is_rtsp_enabled = False
package_channel.name = "Package Camera" package_channel.name = "Package Camera"

View file

@ -4,7 +4,7 @@ from pyunifiprotect.data import NVR, Light
from homeassistant.core import HomeAssistant 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 from tests.components.diagnostics import get_diagnostics_for_config_entry
@ -17,7 +17,7 @@ async def test_diagnostics(
light1 = mock_light.copy() light1 = mock_light.copy()
light1._api = mock_entry.api light1._api = mock_entry.api
light1.name = "Test Light 1" light1.name = "Test Light 1"
light1.id = "lightid1" regenerate_device_ids(light1)
mock_entry.api.bootstrap.lights = { mock_entry.api.bootstrap.lights = {
light1.id: light1, light1.id: light1,

View file

@ -16,7 +16,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from . import _patch_discovery from . import _patch_discovery
from .conftest import MockBootstrap, MockEntityFixture from .conftest import MockBootstrap, MockEntityFixture, regenerate_device_ids
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -212,8 +212,7 @@ async def test_device_remove_devices(
light1 = mock_light.copy() light1 = mock_light.copy()
light1._api = mock_entry.api light1._api = mock_entry.api
light1.name = "Test Light 1" light1.name = "Test Light 1"
light1.id = "lightid1" regenerate_device_ids(light1)
light1.mac = "AABBCCDDEEFF"
mock_entry.api.bootstrap.lights = { mock_entry.api.bootstrap.lights = {
light1.id: light1, light1.id: light1,

View file

@ -57,7 +57,7 @@ async def test_light_setup(
): ):
"""Test light entity setup.""" """Test light entity setup."""
unique_id = light[0].id unique_id = light[0].mac
entity_id = light[1] entity_id = light[1]
entity_registry = er.async_get(hass) entity_registry = er.async_get(hass)

View file

@ -60,7 +60,7 @@ async def test_lock_setup(
): ):
"""Test lock entity setup.""" """Test lock entity setup."""
unique_id = f"{doorlock[0].id}_lock" unique_id = f"{doorlock[0].mac}_lock"
entity_id = doorlock[1] entity_id = doorlock[1]
entity_registry = er.async_get(hass) entity_registry = er.async_get(hass)

View file

@ -66,7 +66,7 @@ async def test_media_player_setup(
): ):
"""Test media_player entity 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_id = camera[1]
entity_registry = er.async_get(hass) entity_registry = er.async_get(hass)

View file

@ -12,7 +12,7 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er 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( async def test_migrate_reboot_button(
@ -23,12 +23,13 @@ async def test_migrate_reboot_button(
light1 = mock_light.copy() light1 = mock_light.copy()
light1._api = mock_entry.api light1._api = mock_entry.api
light1.name = "Test Light 1" light1.name = "Test Light 1"
light1.id = "lightid1" regenerate_device_ids(light1)
light2 = mock_light.copy() light2 = mock_light.copy()
light2._api = mock_entry.api light2._api = mock_entry.api
light2.name = "Test Light 2" light2.name = "Test Light 2"
light2.id = "lightid2" regenerate_device_ids(light2)
mock_entry.api.bootstrap.lights = { mock_entry.api.bootstrap.lights = {
light1.id: light1, light1.id: light1,
light2.id: light2, light2.id: light2,
@ -42,7 +43,7 @@ async def test_migrate_reboot_button(
registry.async_get_or_create( registry.async_get_or_create(
Platform.BUTTON, Platform.BUTTON,
DOMAIN, DOMAIN,
f"{light2.id}_reboot", f"{light2.mac}_reboot",
config_entry=mock_entry.entry, config_entry=mock_entry.entry,
) )
@ -59,20 +60,21 @@ async def test_migrate_reboot_button(
): ):
if entity.domain == Platform.BUTTON.value: if entity.domain == Platform.BUTTON.value:
buttons.append(entity) buttons.append(entity)
print(entity.entity_id)
assert len(buttons) == 2 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") is None
assert registry.async_get(f"{Platform.BUTTON}.test_light_1_reboot_device_2") 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 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") is None
assert registry.async_get(f"{Platform.BUTTON}.test_light_2_reboot_device_2") 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 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( 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 = mock_light.copy()
light1._api = mock_entry.api light1._api = mock_entry.api
light1.name = "Test Light 1" light1.name = "Test Light 1"
light1.id = "lightid1" regenerate_device_ids(light1)
light2_id, _ = generate_random_ids()
mock_entry.api.bootstrap.lights = { mock_entry.api.bootstrap.lights = {
light1.id: light1, light1.id: light1,
@ -92,7 +96,7 @@ async def test_migrate_reboot_button_no_device(
registry = er.async_get(hass) registry = er.async_get(hass)
registry.async_get_or_create( 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) 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) buttons.append(entity)
assert len(buttons) == 2 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 is not None
assert light.unique_id == "lightid2" assert light.unique_id == light2_id
async def test_migrate_reboot_button_fail( async def test_migrate_reboot_button_fail(
@ -123,7 +127,7 @@ async def test_migrate_reboot_button_fail(
light1 = mock_light.copy() light1 = mock_light.copy()
light1._api = mock_entry.api light1._api = mock_entry.api
light1.name = "Test Light 1" light1.name = "Test Light 1"
light1.id = "lightid1" regenerate_device_ids(light1)
mock_entry.api.bootstrap.lights = { mock_entry.api.bootstrap.lights = {
light1.id: light1, light1.id: light1,
@ -155,4 +159,47 @@ async def test_migrate_reboot_button_fail(
light = registry.async_get(f"{Platform.BUTTON}.test_light_1") light = registry.async_get(f"{Platform.BUTTON}.test_light_1")
assert light is not None 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"

View file

@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er 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") @pytest.fixture(name="device")
@ -165,22 +165,22 @@ async def test_set_chime_paired_doorbells(
} }
camera1 = mock_camera.copy() camera1 = mock_camera.copy()
camera1.id = "cameraid1"
camera1.name = "Test Camera 1" camera1.name = "Test Camera 1"
camera1._api = mock_entry.api camera1._api = mock_entry.api
camera1.channels[0]._api = mock_entry.api camera1.channels[0]._api = mock_entry.api
camera1.channels[1]._api = mock_entry.api camera1.channels[1]._api = mock_entry.api
camera1.channels[2]._api = mock_entry.api camera1.channels[2]._api = mock_entry.api
camera1.feature_flags.has_chime = True camera1.feature_flags.has_chime = True
regenerate_device_ids(camera1)
camera2 = mock_camera.copy() camera2 = mock_camera.copy()
camera2.id = "cameraid2"
camera2.name = "Test Camera 2" camera2.name = "Test Camera 2"
camera2._api = mock_entry.api camera2._api = mock_entry.api
camera2.channels[0]._api = mock_entry.api camera2.channels[0]._api = mock_entry.api
camera2.channels[1]._api = mock_entry.api camera2.channels[1]._api = mock_entry.api
camera2.channels[2]._api = mock_entry.api camera2.channels[2]._api = mock_entry.api
camera2.feature_flags.has_chime = True camera2.feature_flags.has_chime = True
regenerate_device_ids(camera2)
mock_entry.api.bootstrap.cameras = { mock_entry.api.bootstrap.cameras = {
camera1.id: camera1, camera1.id: camera1,
@ -210,5 +210,5 @@ async def test_set_chime_paired_doorbells(
) )
mock_entry.api.update_device.assert_called_once_with( 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])}
) )

View file

@ -238,7 +238,7 @@ async def test_switch_setup_light(
description = LIGHT_SWITCHES[0] 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_id = f"switch.test_light_{description.name.lower().replace(' ', '_')}"
entity = entity_registry.async_get(entity_id) entity = entity_registry.async_get(entity_id)
@ -282,7 +282,7 @@ async def test_switch_setup_camera_all(
description_entity_name = ( description_entity_name = (
description.name.lower().replace(":", "").replace(" ", "_") 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_id = f"switch.test_camera_{description_entity_name}"
entity = entity_registry.async_get(entity_id) entity = entity_registry.async_get(entity_id)
@ -329,7 +329,7 @@ async def test_switch_setup_camera_none(
description_entity_name = ( description_entity_name = (
description.name.lower().replace(":", "").replace(" ", "_") 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_id = f"switch.test_camera_{description_entity_name}"
entity = entity_registry.async_get(entity_id) entity = entity_registry.async_get(entity_id)