UniFi Protect cleanup and enable unadopted devices (#73860)

This commit is contained in:
Christopher Bailey 2022-06-22 16:57:21 -04:00 committed by GitHub
parent 5c5fd746fd
commit 01a9367281
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 258 additions and 114 deletions

View file

@ -61,6 +61,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
subscribed_models=DEVICES_FOR_SUBSCRIBE,
override_connection_host=entry.options.get(CONF_OVERRIDE_CHOST, False),
ignore_stats=not entry.options.get(CONF_ALL_UPDATES, False),
ignore_unadopted=False,
)
_LOGGER.debug("Connect to UniFi Protect")
data_service = ProtectData(hass, protect, SCAN_INTERVAL, entry)
@ -127,7 +128,9 @@ async def async_remove_config_entry_device(
}
api = async_ufp_instance_for_config_entry_ids(hass, {config_entry.entry_id})
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.bootstrap, DEVICES_THAT_ADOPT)
)
if api.bootstrap.nvr.mac in unifi_macs:
return False
for device in async_get_devices(api.bootstrap, DEVICES_THAT_ADOPT):
if device.is_adopted_by_us and device.mac in unifi_macs:
return False
return True

View file

@ -35,7 +35,6 @@ from .entity import (
async_all_device_entities,
)
from .models import PermRequired, ProtectRequiredKeysMixin
from .utils import async_get_is_highfps
_LOGGER = logging.getLogger(__name__)
_KEY_DOOR = "door"
@ -103,7 +102,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
icon="mdi:video-high-definition",
entity_category=EntityCategory.DIAGNOSTIC,
ufp_required_field="feature_flags.has_highfps",
ufp_value_fn=async_get_is_highfps,
ufp_value="is_high_fps_enabled",
ufp_perm=PermRequired.NO_WRITE,
),
ProtectBinaryEntityDescription(
@ -386,12 +385,15 @@ def _async_motion_entities(
) -> list[ProtectDeviceEntity]:
entities: list[ProtectDeviceEntity] = []
for device in data.api.bootstrap.cameras.values():
if not device.is_adopted_by_us:
continue
for description in MOTION_SENSORS:
entities.append(ProtectEventBinarySensor(data, device, description))
_LOGGER.debug(
"Adding binary sensor entity %s for %s",
description.name,
device.name,
device.display_name,
)
return entities
@ -468,9 +470,9 @@ class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity):
slot = self._disk.slot
self._attr_available = False
if self.device.system_info.ustorage is None:
return
# should not be possible since it would require user to
# _downgrade_ to make ustorage disppear
assert self.device.system_info.ustorage is not None
for disk in self.device.system_info.ustorage.disks:
if disk.slot == slot:
self._disk = disk

View file

@ -103,7 +103,7 @@ class ProtectButton(ProtectDeviceEntity, ButtonEntity):
) -> None:
"""Initialize an UniFi camera."""
super().__init__(data, device, description)
self._attr_name = f"{self.device.name} {self.entity_description.name}"
self._attr_name = f"{self.device.display_name} {self.entity_description.name}"
async def async_press(self) -> None:
"""Press the button."""

View file

@ -36,9 +36,14 @@ def get_camera_channels(
) -> Generator[tuple[UFPCamera, CameraChannel, bool], None, None]:
"""Get all the camera channels."""
for camera in protect.bootstrap.cameras.values():
if not camera.is_adopted_by_us:
continue
if not camera.channels:
_LOGGER.warning(
"Camera does not have any channels: %s (id: %s)", camera.name, camera.id
"Camera does not have any channels: %s (id: %s)",
camera.display_name,
camera.id,
)
continue
@ -116,10 +121,10 @@ class ProtectCamera(ProtectDeviceEntity, Camera):
if self._secure:
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.display_name} {self.channel.name}"
else:
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.display_name} {self.channel.name} Insecure"
# only the default (first) channel is enabled by default
self._attr_entity_registry_enabled_default = is_default and secure

View file

@ -175,9 +175,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
user_input[CONF_VERIFY_SSL] = False
nvr_data, errors = await self._async_get_nvr_data(user_input)
if nvr_data and not errors:
return self._async_create_entry(
nvr_data.name or nvr_data.type, user_input
)
return self._async_create_entry(nvr_data.display_name, user_input)
placeholders = {
"name": discovery_info["hostname"]
@ -323,9 +321,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(nvr_data.mac)
self._abort_if_unique_id_configured()
return self._async_create_entry(
nvr_data.name or nvr_data.type, user_input
)
return self._async_create_entry(nvr_data.display_name, user_input)
user_input = user_input or {}
return self.async_show_form(

View file

@ -120,7 +120,9 @@ class ProtectData:
@callback
def _async_process_ws_message(self, message: WSSubscriptionMessage) -> None:
# removed packets are not processed yet
if message.new_obj is None: # pragma: no cover
if message.new_obj is None or not getattr(
message.new_obj, "is_adopted_by_us", True
):
return
if message.new_obj.model in DEVICES_WITH_ENTITIES:

View file

@ -44,6 +44,9 @@ def _async_device_entities(
entities: list[ProtectDeviceEntity] = []
for device in data.get_by_types({model_type}):
if not device.is_adopted_by_us:
continue
assert isinstance(device, (Camera, Light, Sensor, Viewer, Doorlock, Chime))
for description in descs:
if description.ufp_perm is not None:
@ -69,7 +72,7 @@ def _async_device_entities(
"Adding %s entity %s for %s",
klass.__name__,
description.name,
device.name,
device.display_name,
)
return entities
@ -126,12 +129,12 @@ class ProtectDeviceEntity(Entity):
if description is None:
self._attr_unique_id = f"{self.device.mac}"
self._attr_name = f"{self.device.name}"
self._attr_name = f"{self.device.display_name}"
else:
self.entity_description = description
self._attr_unique_id = f"{self.device.mac}_{description.key}"
name = description.name or ""
self._attr_name = f"{self.device.name} {name.title()}"
self._attr_name = f"{self.device.display_name} {name.title()}"
self._attr_attribution = DEFAULT_ATTRIBUTION
self._async_set_device_info()
@ -147,7 +150,7 @@ class ProtectDeviceEntity(Entity):
@callback
def _async_set_device_info(self) -> None:
self._attr_device_info = DeviceInfo(
name=self.device.name,
name=self.device.display_name,
manufacturer=DEFAULT_BRAND,
model=self.device.type,
via_device=(DOMAIN, self.data.api.bootstrap.nvr.mac),
@ -214,7 +217,7 @@ class ProtectNVREntity(ProtectDeviceEntity):
connections={(dr.CONNECTION_NETWORK_MAC, self.device.mac)},
identifiers={(DOMAIN, self.device.mac)},
manufacturer=DEFAULT_BRAND,
name=self.device.name,
name=self.device.display_name,
model=self.device.type,
sw_version=str(self.device.version),
configuration_url=self.device.api.base_url,

View file

@ -27,12 +27,12 @@ async def async_setup_entry(
data: ProtectData = hass.data[DOMAIN][entry.entry_id]
entities = []
for device in data.api.bootstrap.lights.values():
if not device.is_adopted_by_us:
continue
if device.can_write(data.api.bootstrap.auth_user):
entities.append(ProtectLight(data, device))
if not entities:
return
async_add_entities(entities)

View file

@ -26,13 +26,14 @@ async def async_setup_entry(
"""Set up locks on a UniFi Protect NVR."""
data: ProtectData = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
ProtectLock(
data,
lock,
)
for lock in data.api.bootstrap.doorlocks.values()
)
entities = []
for device in data.api.bootstrap.doorlocks.values():
if not device.is_adopted_by_us:
continue
entities.append(ProtectLock(data, device))
async_add_entities(entities)
class ProtectLock(ProtectDeviceEntity, LockEntity):
@ -53,7 +54,7 @@ class ProtectLock(ProtectDeviceEntity, LockEntity):
LockEntityDescription(key="lock"),
)
self._attr_name = f"{self.device.name} Lock"
self._attr_name = f"{self.device.display_name} Lock"
@callback
def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None:
@ -82,10 +83,10 @@ class ProtectLock(ProtectDeviceEntity, LockEntity):
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the lock."""
_LOGGER.debug("Unlocking %s", self.device.name)
_LOGGER.debug("Unlocking %s", self.device.display_name)
return await self.device.open_lock()
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the lock."""
_LOGGER.debug("Locking %s", self.device.name)
_LOGGER.debug("Locking %s", self.device.display_name)
return await self.device.close_lock()

View file

@ -40,16 +40,14 @@ async def async_setup_entry(
"""Discover cameras with speakers on a UniFi Protect NVR."""
data: ProtectData = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
[
ProtectMediaPlayer(
data,
camera,
)
for camera in data.api.bootstrap.cameras.values()
if camera.feature_flags.has_speaker
]
)
entities = []
for device in data.api.bootstrap.cameras.values():
if not device.is_adopted_by_us or not device.feature_flags.has_speaker:
continue
entities.append(ProtectMediaPlayer(data, device))
async_add_entities(entities)
class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity):
@ -79,7 +77,7 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity):
),
)
self._attr_name = f"{self.device.name} Speaker"
self._attr_name = f"{self.device.display_name} Speaker"
self._attr_media_content_type = MEDIA_TYPE_MUSIC
@callback
@ -108,7 +106,7 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity):
self.device.talkback_stream is not None
and self.device.talkback_stream.is_running
):
_LOGGER.debug("Stopping playback for %s Speaker", self.device.name)
_LOGGER.debug("Stopping playback for %s Speaker", self.device.display_name)
await self.device.stop_audio()
self._async_updated_event(self.device)
@ -126,7 +124,9 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity):
if media_type != MEDIA_TYPE_MUSIC:
raise HomeAssistantError("Only music media type is supported")
_LOGGER.debug("Playing Media %s for %s Speaker", media_id, self.device.name)
_LOGGER.debug(
"Playing Media %s for %s Speaker", media_id, self.device.display_name
)
await self.async_media_stop()
try:
await self.device.play_audio(media_id, blocking=False)

View file

@ -14,8 +14,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er
from .utils import async_device_by_id
_LOGGER = logging.getLogger(__name__)
@ -69,7 +67,7 @@ async def async_migrate_buttons(
bootstrap = await async_get_bootstrap(protect)
count = 0
for button in to_migrate:
device = async_device_by_id(bootstrap, button.unique_id)
device = bootstrap.get_device_from_id(button.unique_id)
if device is None:
continue
@ -130,7 +128,7 @@ async def async_migrate_device_ids(
if parts[0] == bootstrap.nvr.id:
device: NVR | ProtectAdoptableDeviceModel | None = bootstrap.nvr
else:
device = async_device_by_id(bootstrap, parts[0])
device = bootstrap.get_device_from_id(parts[0])
if device is None:
continue

View file

@ -5,9 +5,9 @@ from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from enum import Enum
import logging
from typing import Any, Generic, TypeVar
from typing import Any, Generic, TypeVar, Union
from pyunifiprotect.data import ProtectDeviceModel
from pyunifiprotect.data import NVR, ProtectAdoptableDeviceModel
from homeassistant.helpers.entity import EntityDescription
@ -15,7 +15,7 @@ from .utils import get_nested_attr
_LOGGER = logging.getLogger(__name__)
T = TypeVar("T", bound=ProtectDeviceModel)
T = TypeVar("T", bound=Union[ProtectAdoptableDeviceModel, NVR])
class PermRequired(int, Enum):
@ -63,7 +63,7 @@ class ProtectSetableKeysMixin(ProtectRequiredKeysMixin[T]):
async def ufp_set(self, obj: T, value: Any) -> None:
"""Set value for UniFi Protect device."""
_LOGGER.debug("Setting %s to %s for %s", self.name, value, obj.name)
_LOGGER.debug("Setting %s to %s for %s", self.name, value, obj.display_name)
if self.ufp_set_method is not None:
await getattr(obj, self.ufp_set_method)(value)
elif self.ufp_set_method_fn is not None:

View file

@ -143,7 +143,7 @@ def _get_doorbell_options(api: ProtectApiClient) -> list[dict[str, Any]]:
def _get_paired_camera_options(api: ProtectApiClient) -> list[dict[str, Any]]:
options = [{"id": TYPE_EMPTY_VALUE, "name": "Not Paired"}]
for camera in api.bootstrap.cameras.values():
options.append({"id": camera.id, "name": camera.name or camera.type})
options.append({"id": camera.id, "name": camera.display_name or camera.type})
return options
@ -353,7 +353,7 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity):
) -> None:
"""Initialize the unifi protect select entity."""
super().__init__(data, device, description)
self._attr_name = f"{self.device.name} {self.entity_description.name}"
self._attr_name = f"{self.device.display_name} {self.entity_description.name}"
self._async_set_options()
@callback
@ -421,7 +421,10 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity):
timeout_msg = f" with timeout of {duration} minute(s)"
_LOGGER.debug(
'Setting message for %s to "%s"%s', self.device.name, message, timeout_msg
'Setting message for %s to "%s"%s',
self.device.display_name,
message,
timeout_msg,
)
await self.device.set_lcd_text(
DoorbellMessageType.CUSTOM_MESSAGE, message, reset_at=reset_at

View file

@ -108,7 +108,7 @@ def _get_alarm_sound(obj: Sensor) -> str:
ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
ProtectSensorEntityDescription[ProtectDeviceModel](
ProtectSensorEntityDescription(
key="uptime",
name="Uptime",
icon="mdi:clock",
@ -353,7 +353,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
name="Paired Camera",
icon="mdi:cctv",
entity_category=EntityCategory.DIAGNOSTIC,
ufp_value="camera.name",
ufp_value="camera.display_name",
ufp_perm=PermRequired.NO_WRITE,
),
)
@ -373,13 +373,13 @@ DOORLOCK_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
name="Paired Camera",
icon="mdi:cctv",
entity_category=EntityCategory.DIAGNOSTIC,
ufp_value="camera.name",
ufp_value="camera.display_name",
ufp_perm=PermRequired.NO_WRITE,
),
)
NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
ProtectSensorEntityDescription[ProtectDeviceModel](
ProtectSensorEntityDescription(
key="uptime",
name="Uptime",
icon="mdi:clock",
@ -541,7 +541,7 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
name="Paired Camera",
icon="mdi:cctv",
entity_category=EntityCategory.DIAGNOSTIC,
ufp_value="camera.name",
ufp_value="camera.display_name",
ufp_perm=PermRequired.NO_WRITE,
),
)
@ -618,11 +618,14 @@ def _async_motion_entities(
entities: list[ProtectDeviceEntity] = []
for device in data.api.bootstrap.cameras.values():
for description in MOTION_TRIP_SENSORS:
if not device.is_adopted_by_us:
continue
entities.append(ProtectDeviceSensor(data, device, description))
_LOGGER.debug(
"Adding trip sensor entity %s for %s",
description.name,
device.name,
device.display_name,
)
if not device.feature_flags.has_smart_detect:
@ -633,7 +636,7 @@ def _async_motion_entities(
_LOGGER.debug(
"Adding sensor entity %s for %s",
description.name,
device.name,
device.display_name,
)
return entities

View file

@ -22,7 +22,6 @@ from .const import DOMAIN
from .data import ProtectData
from .entity import ProtectDeviceEntity, async_all_device_entities
from .models import PermRequired, ProtectSetableKeysMixin, T
from .utils import async_get_is_highfps
_LOGGER = logging.getLogger(__name__)
@ -81,7 +80,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
icon="mdi:video-high-definition",
entity_category=EntityCategory.CONFIG,
ufp_required_field="feature_flags.has_highfps",
ufp_value_fn=async_get_is_highfps,
ufp_value="is_high_fps_enabled",
ufp_set_method_fn=_set_highfps,
ufp_perm=PermRequired.WRITE,
),
@ -328,7 +327,7 @@ class ProtectSwitch(ProtectDeviceEntity, SwitchEntity):
) -> None:
"""Initialize an UniFi Protect Switch."""
super().__init__(data, device, description)
self._attr_name = f"{self.device.name} {self.entity_description.name}"
self._attr_name = f"{self.device.display_name} {self.entity_description.name}"
self._switch_type = self.entity_description.key
if not isinstance(self.device, Camera):
@ -362,7 +361,9 @@ class ProtectSwitch(ProtectDeviceEntity, SwitchEntity):
if self._switch_type == _KEY_PRIVACY_MODE:
assert isinstance(self.device, Camera)
_LOGGER.debug("Setting Privacy Mode to false for %s", self.device.name)
_LOGGER.debug(
"Setting Privacy Mode to false for %s", self.device.display_name
)
await self.device.set_privacy(
False, self._previous_mic_level, self._previous_record_mode
)

View file

@ -9,18 +9,15 @@ from typing import Any
from pyunifiprotect.data import (
Bootstrap,
Camera,
Light,
LightModeEnableType,
LightModeType,
ProtectAdoptableDeviceModel,
ProtectDeviceModel,
VideoMode,
)
from homeassistant.core import HomeAssistant, callback
from .const import DEVICES_THAT_ADOPT, ModelType
from .const import ModelType
def get_nested_attr(obj: Any, attr: str) -> Any:
@ -79,30 +76,10 @@ def async_get_devices_by_type(
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(
bootstrap: Bootstrap, model_type: Iterable[ModelType]
) -> Generator[ProtectDeviceModel, None, None]:
) -> Generator[ProtectAdoptableDeviceModel, None, None]:
"""Return all device by type."""
return (
device
@ -111,13 +88,6 @@ def async_get_devices(
)
@callback
def async_get_is_highfps(obj: Camera) -> bool:
"""Return if camera has High FPS mode enabled."""
return bool(obj.video_mode == VideoMode.HIGH_FPS)
@callback
def async_get_light_motion_current(obj: Light) -> str:
"""Get light motion mode for Flood Light."""

View file

@ -284,7 +284,7 @@ def ids_from_device_description(
def generate_random_ids() -> tuple[str, str]:
"""Generate random IDs for device."""
return random_hex(24).upper(), random_hex(12).upper()
return random_hex(24).lower(), random_hex(12).upper()
def regenerate_device_ids(device: ProtectAdoptableDeviceModel) -> None:

View file

@ -36,6 +36,7 @@ from .conftest import (
MockEntityFixture,
assert_entity_counts,
ids_from_device_description,
regenerate_device_ids,
reset_objects,
)
@ -65,11 +66,22 @@ async def camera_fixture(
camera_obj.last_ring = now - timedelta(hours=1)
camera_obj.is_dark = False
camera_obj.is_motion_detected = False
regenerate_device_ids(camera_obj)
no_camera_obj = mock_camera.copy()
no_camera_obj._api = mock_entry.api
no_camera_obj.channels[0]._api = mock_entry.api
no_camera_obj.channels[1]._api = mock_entry.api
no_camera_obj.channels[2]._api = mock_entry.api
no_camera_obj.name = "Unadopted Camera"
no_camera_obj.is_adopted = False
regenerate_device_ids(no_camera_obj)
reset_objects(mock_entry.api.bootstrap)
mock_entry.api.bootstrap.nvr.system_info.storage.devices = []
mock_entry.api.bootstrap.cameras = {
camera_obj.id: camera_obj,
no_camera_obj.id: no_camera_obj,
}
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
@ -135,6 +147,7 @@ async def camera_none_fixture(
reset_objects(mock_entry.api.bootstrap)
mock_entry.api.bootstrap.nvr.system_info.storage.devices = []
mock_entry.api.bootstrap.nvr.system_info.ustorage = None
mock_entry.api.bootstrap.cameras = {
camera_obj.id: camera_obj,
}
@ -142,7 +155,7 @@ async def camera_none_fixture(
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
await hass.async_block_till_done()
assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8)
assert_entity_counts(hass, Platform.BINARY_SENSOR, 2, 2)
yield camera_obj

View file

@ -529,13 +529,22 @@ async def test_camera_ws_update(
new_camera = camera[0].copy()
new_camera.is_recording = True
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = new_camera
no_camera = camera[0].copy()
no_camera.is_adopted = False
new_bootstrap.cameras = {new_camera.id: new_camera}
mock_entry.api.bootstrap = new_bootstrap
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = new_camera
mock_entry.api.ws_subscription(mock_msg)
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = no_camera
mock_entry.api.ws_subscription(mock_msg)
await hass.async_block_till_done()
state = hass.states.get(camera[1])

View file

@ -242,3 +242,27 @@ async def test_device_remove_devices(
await remove_device(await hass_ws_client(hass), dead_device_entry.id, entry_id)
is True
)
async def test_device_remove_devices_nvr(
hass: HomeAssistant,
mock_entry: MockEntityFixture,
hass_ws_client: Callable[
[HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse]
],
) -> None:
"""Test we can only remove a NVR device that no longer exists."""
assert await async_setup_component(hass, "config", {})
mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap)
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
await hass.async_block_till_done()
entry_id = mock_entry.entry.entry_id
device_registry = dr.async_get(hass)
live_device_entry = list(device_registry.devices.values())[0]
assert (
await remove_device(await hass_ws_client(hass), live_device_entry.id, entry_id)
is False
)

View file

@ -20,7 +20,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .conftest import MockEntityFixture, assert_entity_counts
from .conftest import MockEntityFixture, assert_entity_counts, regenerate_device_ids
@pytest.fixture(name="light")
@ -36,9 +36,17 @@ async def light_fixture(
light_obj._api = mock_entry.api
light_obj.name = "Test Light"
light_obj.is_light_on = False
regenerate_device_ids(light_obj)
no_light_obj = mock_light.copy()
no_light_obj._api = mock_entry.api
no_light_obj.name = "Unadopted Light"
no_light_obj.is_adopted = False
regenerate_device_ids(no_light_obj)
mock_entry.api.bootstrap.lights = {
light_obj.id: light_obj,
no_light_obj.id: no_light_obj,
}
await hass.config_entries.async_setup(mock_entry.entry.entry_id)

View file

@ -23,7 +23,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .conftest import MockEntityFixture, assert_entity_counts
from .conftest import MockEntityFixture, assert_entity_counts, regenerate_device_ids
@pytest.fixture(name="doorlock")
@ -39,9 +39,17 @@ async def doorlock_fixture(
lock_obj._api = mock_entry.api
lock_obj.name = "Test Lock"
lock_obj.lock_status = LockStatusType.OPEN
regenerate_device_ids(lock_obj)
no_lock_obj = mock_doorlock.copy()
no_lock_obj._api = mock_entry.api
no_lock_obj.name = "Unadopted Lock"
no_lock_obj.is_adopted = False
regenerate_device_ids(no_lock_obj)
mock_entry.api.bootstrap.doorlocks = {
lock_obj.id: lock_obj,
no_lock_obj.id: no_lock_obj,
}
await hass.config_entries.async_setup(mock_entry.entry.entry_id)

View file

@ -26,7 +26,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from .conftest import MockEntityFixture, assert_entity_counts
from .conftest import MockEntityFixture, assert_entity_counts, regenerate_device_ids
@pytest.fixture(name="camera")
@ -45,9 +45,20 @@ async def camera_fixture(
camera_obj.channels[2]._api = mock_entry.api
camera_obj.name = "Test Camera"
camera_obj.feature_flags.has_speaker = True
regenerate_device_ids(camera_obj)
no_camera_obj = mock_camera.copy()
no_camera_obj._api = mock_entry.api
no_camera_obj.channels[0]._api = mock_entry.api
no_camera_obj.channels[1]._api = mock_entry.api
no_camera_obj.channels[2]._api = mock_entry.api
no_camera_obj.name = "Unadopted Camera"
no_camera_obj.is_adopted = False
regenerate_device_ids(no_camera_obj)
mock_entry.api.bootstrap.cameras = {
camera_obj.id: camera_obj,
no_camera_obj.id: no_camera_obj,
}
await hass.config_entries.async_setup(mock_entry.entry.entry_id)

View file

@ -5,6 +5,8 @@ from __future__ import annotations
from unittest.mock import AsyncMock
from pyunifiprotect.data import Light
from pyunifiprotect.data.bootstrap import ProtectDeviceRef
from pyunifiprotect.exceptions import NvrError
from homeassistant.components.unifiprotect.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
@ -34,6 +36,10 @@ async def test_migrate_reboot_button(
light1.id: light1,
light2.id: light2,
}
mock_entry.api.bootstrap.id_lookup = {
light1.id: ProtectDeviceRef(id=light1.id, model=light1.model),
light2.id: ProtectDeviceRef(id=light2.id, model=light2.model),
}
mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap)
registry = er.async_get(hass)
@ -77,6 +83,41 @@ async def test_migrate_reboot_button(
assert light.unique_id == f"{light2.mac}_reboot"
async def test_migrate_nvr_mac(
hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light
):
"""Test migrating unique ID of NVR to use MAC address."""
mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap)
nvr = mock_entry.api.bootstrap.nvr
regenerate_device_ids(nvr)
registry = er.async_get(hass)
registry.async_get_or_create(
Platform.SENSOR,
DOMAIN,
f"{nvr.id}_storage_utilization",
config_entry=mock_entry.entry,
)
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
assert registry.async_get(f"{Platform.SENSOR}.{DOMAIN}_storage_utilization") is None
assert (
registry.async_get(f"{Platform.SENSOR}.{DOMAIN}_storage_utilization_2") is None
)
sensor = registry.async_get(
f"{Platform.SENSOR}.{DOMAIN}_{nvr.id}_storage_utilization"
)
assert sensor is not None
assert sensor.unique_id == f"{nvr.mac}_storage_utilization"
async def test_migrate_reboot_button_no_device(
hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light
):
@ -132,6 +173,9 @@ async def test_migrate_reboot_button_fail(
mock_entry.api.bootstrap.lights = {
light1.id: light1,
}
mock_entry.api.bootstrap.id_lookup = {
light1.id: ProtectDeviceRef(id=light1.id, model=light1.model),
}
mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap)
registry = er.async_get(hass)
@ -175,6 +219,9 @@ async def test_migrate_device_mac_button_fail(
mock_entry.api.bootstrap.lights = {
light1.id: light1,
}
mock_entry.api.bootstrap.id_lookup = {
light1.id: ProtectDeviceRef(id=light1.id, model=light1.model)
}
mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap)
registry = er.async_get(hass)
@ -203,3 +250,40 @@ async def test_migrate_device_mac_button_fail(
light = registry.async_get(f"{Platform.BUTTON}.test_light_1")
assert light is not None
assert light.unique_id == f"{light1.id}_reboot"
async def test_migrate_device_mac_bootstrap_fail(
hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light
):
"""Test migrating with a network error."""
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(side_effect=NvrError)
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.SETUP_RETRY