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, subscribed_models=DEVICES_FOR_SUBSCRIBE,
override_connection_host=entry.options.get(CONF_OVERRIDE_CHOST, False), override_connection_host=entry.options.get(CONF_OVERRIDE_CHOST, False),
ignore_stats=not entry.options.get(CONF_ALL_UPDATES, False), ignore_stats=not entry.options.get(CONF_ALL_UPDATES, False),
ignore_unadopted=False,
) )
_LOGGER.debug("Connect to UniFi Protect") _LOGGER.debug("Connect to UniFi Protect")
data_service = ProtectData(hass, protect, SCAN_INTERVAL, entry) 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}) api = async_ufp_instance_for_config_entry_ids(hass, {config_entry.entry_id})
assert api is not None assert api is not None
return api.bootstrap.nvr.mac not in unifi_macs and not any( if api.bootstrap.nvr.mac in unifi_macs:
device.mac in unifi_macs return False
for device in async_get_devices(api.bootstrap, DEVICES_THAT_ADOPT) 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, async_all_device_entities,
) )
from .models import PermRequired, ProtectRequiredKeysMixin from .models import PermRequired, ProtectRequiredKeysMixin
from .utils import async_get_is_highfps
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_KEY_DOOR = "door" _KEY_DOOR = "door"
@ -103,7 +102,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
icon="mdi:video-high-definition", icon="mdi:video-high-definition",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
ufp_required_field="feature_flags.has_highfps", 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, ufp_perm=PermRequired.NO_WRITE,
), ),
ProtectBinaryEntityDescription( ProtectBinaryEntityDescription(
@ -386,12 +385,15 @@ def _async_motion_entities(
) -> list[ProtectDeviceEntity]: ) -> list[ProtectDeviceEntity]:
entities: list[ProtectDeviceEntity] = [] entities: list[ProtectDeviceEntity] = []
for device in data.api.bootstrap.cameras.values(): for device in data.api.bootstrap.cameras.values():
if not device.is_adopted_by_us:
continue
for description in MOTION_SENSORS: for description in MOTION_SENSORS:
entities.append(ProtectEventBinarySensor(data, device, description)) entities.append(ProtectEventBinarySensor(data, device, description))
_LOGGER.debug( _LOGGER.debug(
"Adding binary sensor entity %s for %s", "Adding binary sensor entity %s for %s",
description.name, description.name,
device.name, device.display_name,
) )
return entities return entities
@ -468,9 +470,9 @@ class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity):
slot = self._disk.slot slot = self._disk.slot
self._attr_available = False self._attr_available = False
if self.device.system_info.ustorage is None: # should not be possible since it would require user to
return # _downgrade_ to make ustorage disppear
assert self.device.system_info.ustorage is not None
for disk in self.device.system_info.ustorage.disks: for disk in self.device.system_info.ustorage.disks:
if disk.slot == slot: if disk.slot == slot:
self._disk = disk self._disk = disk

View file

@ -103,7 +103,7 @@ class ProtectButton(ProtectDeviceEntity, ButtonEntity):
) -> None: ) -> None:
"""Initialize an UniFi camera.""" """Initialize an UniFi camera."""
super().__init__(data, device, description) 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: async def async_press(self) -> None:
"""Press the button.""" """Press the button."""

View file

@ -36,9 +36,14 @@ def get_camera_channels(
) -> Generator[tuple[UFPCamera, CameraChannel, bool], None, None]: ) -> Generator[tuple[UFPCamera, CameraChannel, bool], None, None]:
"""Get all the camera channels.""" """Get all the camera channels."""
for camera in protect.bootstrap.cameras.values(): for camera in protect.bootstrap.cameras.values():
if not camera.is_adopted_by_us:
continue
if not camera.channels: if not camera.channels:
_LOGGER.warning( _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 continue
@ -116,10 +121,10 @@ class ProtectCamera(ProtectDeviceEntity, Camera):
if self._secure: if self._secure:
self._attr_unique_id = f"{self.device.mac}_{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.display_name} {self.channel.name}"
else: else:
self._attr_unique_id = f"{self.device.mac}_{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.display_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

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

View file

@ -120,7 +120,9 @@ class ProtectData:
@callback @callback
def _async_process_ws_message(self, message: WSSubscriptionMessage) -> None: def _async_process_ws_message(self, message: WSSubscriptionMessage) -> None:
# removed packets are not processed yet # 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 return
if message.new_obj.model in DEVICES_WITH_ENTITIES: if message.new_obj.model in DEVICES_WITH_ENTITIES:

View file

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

View file

@ -26,13 +26,14 @@ async def async_setup_entry(
"""Set up locks on a UniFi Protect NVR.""" """Set up locks on a UniFi Protect NVR."""
data: ProtectData = hass.data[DOMAIN][entry.entry_id] data: ProtectData = hass.data[DOMAIN][entry.entry_id]
async_add_entities( entities = []
ProtectLock( for device in data.api.bootstrap.doorlocks.values():
data, if not device.is_adopted_by_us:
lock, continue
)
for lock in data.api.bootstrap.doorlocks.values() entities.append(ProtectLock(data, device))
)
async_add_entities(entities)
class ProtectLock(ProtectDeviceEntity, LockEntity): class ProtectLock(ProtectDeviceEntity, LockEntity):
@ -53,7 +54,7 @@ class ProtectLock(ProtectDeviceEntity, LockEntity):
LockEntityDescription(key="lock"), LockEntityDescription(key="lock"),
) )
self._attr_name = f"{self.device.name} Lock" self._attr_name = f"{self.device.display_name} Lock"
@callback @callback
def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: 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: async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the lock.""" """Unlock the lock."""
_LOGGER.debug("Unlocking %s", self.device.name) _LOGGER.debug("Unlocking %s", self.device.display_name)
return await self.device.open_lock() return await self.device.open_lock()
async def async_lock(self, **kwargs: Any) -> None: async def async_lock(self, **kwargs: Any) -> None:
"""Lock the lock.""" """Lock the lock."""
_LOGGER.debug("Locking %s", self.device.name) _LOGGER.debug("Locking %s", self.device.display_name)
return await self.device.close_lock() 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.""" """Discover cameras with speakers on a UniFi Protect NVR."""
data: ProtectData = hass.data[DOMAIN][entry.entry_id] data: ProtectData = hass.data[DOMAIN][entry.entry_id]
async_add_entities( entities = []
[ for device in data.api.bootstrap.cameras.values():
ProtectMediaPlayer( if not device.is_adopted_by_us or not device.feature_flags.has_speaker:
data, continue
camera,
) entities.append(ProtectMediaPlayer(data, device))
for camera in data.api.bootstrap.cameras.values()
if camera.feature_flags.has_speaker async_add_entities(entities)
]
)
class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): 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 self._attr_media_content_type = MEDIA_TYPE_MUSIC
@callback @callback
@ -108,7 +106,7 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity):
self.device.talkback_stream is not None self.device.talkback_stream is not None
and self.device.talkback_stream.is_running 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() await self.device.stop_audio()
self._async_updated_event(self.device) self._async_updated_event(self.device)
@ -126,7 +124,9 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity):
if media_type != MEDIA_TYPE_MUSIC: if media_type != MEDIA_TYPE_MUSIC:
raise HomeAssistantError("Only music media type is supported") 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() await self.async_media_stop()
try: try:
await self.device.play_audio(media_id, blocking=False) 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.exceptions import ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from .utils import async_device_by_id
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -69,7 +67,7 @@ async def async_migrate_buttons(
bootstrap = await async_get_bootstrap(protect) bootstrap = await async_get_bootstrap(protect)
count = 0 count = 0
for button in to_migrate: 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: if device is None:
continue continue
@ -130,7 +128,7 @@ async def async_migrate_device_ids(
if parts[0] == bootstrap.nvr.id: if parts[0] == bootstrap.nvr.id:
device: NVR | ProtectAdoptableDeviceModel | None = bootstrap.nvr device: NVR | ProtectAdoptableDeviceModel | None = bootstrap.nvr
else: else:
device = async_device_by_id(bootstrap, parts[0]) device = bootstrap.get_device_from_id(parts[0])
if device is None: if device is None:
continue continue

View file

@ -5,9 +5,9 @@ from collections.abc import Callable, Coroutine
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
import logging 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 from homeassistant.helpers.entity import EntityDescription
@ -15,7 +15,7 @@ from .utils import get_nested_attr
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
T = TypeVar("T", bound=ProtectDeviceModel) T = TypeVar("T", bound=Union[ProtectAdoptableDeviceModel, NVR])
class PermRequired(int, Enum): class PermRequired(int, Enum):
@ -63,7 +63,7 @@ class ProtectSetableKeysMixin(ProtectRequiredKeysMixin[T]):
async def ufp_set(self, obj: T, value: Any) -> None: async def ufp_set(self, obj: T, value: Any) -> None:
"""Set value for UniFi Protect device.""" """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: if self.ufp_set_method is not None:
await getattr(obj, self.ufp_set_method)(value) await getattr(obj, self.ufp_set_method)(value)
elif self.ufp_set_method_fn is not None: 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]]: def _get_paired_camera_options(api: ProtectApiClient) -> list[dict[str, Any]]:
options = [{"id": TYPE_EMPTY_VALUE, "name": "Not Paired"}] options = [{"id": TYPE_EMPTY_VALUE, "name": "Not Paired"}]
for camera in api.bootstrap.cameras.values(): 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 return options
@ -353,7 +353,7 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity):
) -> None: ) -> None:
"""Initialize the unifi protect select entity.""" """Initialize the unifi protect select entity."""
super().__init__(data, device, description) 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() self._async_set_options()
@callback @callback
@ -421,7 +421,10 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity):
timeout_msg = f" with timeout of {duration} minute(s)" timeout_msg = f" with timeout of {duration} minute(s)"
_LOGGER.debug( _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( await self.device.set_lcd_text(
DoorbellMessageType.CUSTOM_MESSAGE, message, reset_at=reset_at 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, ...] = ( ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
ProtectSensorEntityDescription[ProtectDeviceModel]( ProtectSensorEntityDescription(
key="uptime", key="uptime",
name="Uptime", name="Uptime",
icon="mdi:clock", icon="mdi:clock",
@ -353,7 +353,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
name="Paired Camera", name="Paired Camera",
icon="mdi:cctv", icon="mdi:cctv",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
ufp_value="camera.name", ufp_value="camera.display_name",
ufp_perm=PermRequired.NO_WRITE, ufp_perm=PermRequired.NO_WRITE,
), ),
) )
@ -373,13 +373,13 @@ DOORLOCK_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
name="Paired Camera", name="Paired Camera",
icon="mdi:cctv", icon="mdi:cctv",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
ufp_value="camera.name", ufp_value="camera.display_name",
ufp_perm=PermRequired.NO_WRITE, ufp_perm=PermRequired.NO_WRITE,
), ),
) )
NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
ProtectSensorEntityDescription[ProtectDeviceModel]( ProtectSensorEntityDescription(
key="uptime", key="uptime",
name="Uptime", name="Uptime",
icon="mdi:clock", icon="mdi:clock",
@ -541,7 +541,7 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
name="Paired Camera", name="Paired Camera",
icon="mdi:cctv", icon="mdi:cctv",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
ufp_value="camera.name", ufp_value="camera.display_name",
ufp_perm=PermRequired.NO_WRITE, ufp_perm=PermRequired.NO_WRITE,
), ),
) )
@ -618,11 +618,14 @@ def _async_motion_entities(
entities: list[ProtectDeviceEntity] = [] entities: list[ProtectDeviceEntity] = []
for device in data.api.bootstrap.cameras.values(): for device in data.api.bootstrap.cameras.values():
for description in MOTION_TRIP_SENSORS: for description in MOTION_TRIP_SENSORS:
if not device.is_adopted_by_us:
continue
entities.append(ProtectDeviceSensor(data, device, description)) entities.append(ProtectDeviceSensor(data, device, description))
_LOGGER.debug( _LOGGER.debug(
"Adding trip sensor entity %s for %s", "Adding trip sensor entity %s for %s",
description.name, description.name,
device.name, device.display_name,
) )
if not device.feature_flags.has_smart_detect: if not device.feature_flags.has_smart_detect:
@ -633,7 +636,7 @@ def _async_motion_entities(
_LOGGER.debug( _LOGGER.debug(
"Adding sensor entity %s for %s", "Adding sensor entity %s for %s",
description.name, description.name,
device.name, device.display_name,
) )
return entities return entities

View file

@ -22,7 +22,6 @@ from .const import DOMAIN
from .data import ProtectData from .data import ProtectData
from .entity import ProtectDeviceEntity, async_all_device_entities from .entity import ProtectDeviceEntity, async_all_device_entities
from .models import PermRequired, ProtectSetableKeysMixin, T from .models import PermRequired, ProtectSetableKeysMixin, T
from .utils import async_get_is_highfps
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -81,7 +80,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
icon="mdi:video-high-definition", icon="mdi:video-high-definition",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
ufp_required_field="feature_flags.has_highfps", 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_set_method_fn=_set_highfps,
ufp_perm=PermRequired.WRITE, ufp_perm=PermRequired.WRITE,
), ),
@ -328,7 +327,7 @@ class ProtectSwitch(ProtectDeviceEntity, SwitchEntity):
) -> None: ) -> None:
"""Initialize an UniFi Protect Switch.""" """Initialize an UniFi Protect Switch."""
super().__init__(data, device, description) 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 self._switch_type = self.entity_description.key
if not isinstance(self.device, Camera): if not isinstance(self.device, Camera):
@ -362,7 +361,9 @@ class ProtectSwitch(ProtectDeviceEntity, SwitchEntity):
if self._switch_type == _KEY_PRIVACY_MODE: if self._switch_type == _KEY_PRIVACY_MODE:
assert isinstance(self.device, Camera) 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( await self.device.set_privacy(
False, self._previous_mic_level, self._previous_record_mode False, self._previous_mic_level, self._previous_record_mode
) )

View file

@ -9,18 +9,15 @@ from typing import Any
from pyunifiprotect.data import ( from pyunifiprotect.data import (
Bootstrap, Bootstrap,
Camera,
Light, Light,
LightModeEnableType, LightModeEnableType,
LightModeType, LightModeType,
ProtectAdoptableDeviceModel, ProtectAdoptableDeviceModel,
ProtectDeviceModel,
VideoMode,
) )
from homeassistant.core import HomeAssistant, callback 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: def get_nested_attr(obj: Any, attr: str) -> Any:
@ -79,30 +76,10 @@ def async_get_devices_by_type(
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(
bootstrap: Bootstrap, model_type: Iterable[ModelType] bootstrap: Bootstrap, model_type: Iterable[ModelType]
) -> Generator[ProtectDeviceModel, None, None]: ) -> Generator[ProtectAdoptableDeviceModel, None, None]:
"""Return all device by type.""" """Return all device by type."""
return ( return (
device 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 @callback
def async_get_light_motion_current(obj: Light) -> str: def async_get_light_motion_current(obj: Light) -> str:
"""Get light motion mode for Flood Light.""" """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]: def generate_random_ids() -> tuple[str, str]:
"""Generate random IDs for device.""" """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: def regenerate_device_ids(device: ProtectAdoptableDeviceModel) -> None:

View file

@ -36,6 +36,7 @@ from .conftest import (
MockEntityFixture, MockEntityFixture,
assert_entity_counts, assert_entity_counts,
ids_from_device_description, ids_from_device_description,
regenerate_device_ids,
reset_objects, reset_objects,
) )
@ -65,11 +66,22 @@ async def camera_fixture(
camera_obj.last_ring = now - timedelta(hours=1) camera_obj.last_ring = now - timedelta(hours=1)
camera_obj.is_dark = False camera_obj.is_dark = False
camera_obj.is_motion_detected = 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) reset_objects(mock_entry.api.bootstrap)
mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] mock_entry.api.bootstrap.nvr.system_info.storage.devices = []
mock_entry.api.bootstrap.cameras = { mock_entry.api.bootstrap.cameras = {
camera_obj.id: camera_obj, camera_obj.id: camera_obj,
no_camera_obj.id: no_camera_obj,
} }
await hass.config_entries.async_setup(mock_entry.entry.entry_id) 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) reset_objects(mock_entry.api.bootstrap)
mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] mock_entry.api.bootstrap.nvr.system_info.storage.devices = []
mock_entry.api.bootstrap.nvr.system_info.ustorage = None
mock_entry.api.bootstrap.cameras = { mock_entry.api.bootstrap.cameras = {
camera_obj.id: camera_obj, 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.config_entries.async_setup(mock_entry.entry.entry_id)
await hass.async_block_till_done() 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 yield camera_obj

View file

@ -529,13 +529,22 @@ async def test_camera_ws_update(
new_camera = camera[0].copy() new_camera = camera[0].copy()
new_camera.is_recording = True new_camera.is_recording = True
mock_msg = Mock() no_camera = camera[0].copy()
mock_msg.changed_data = {} no_camera.is_adopted = False
mock_msg.new_obj = new_camera
new_bootstrap.cameras = {new_camera.id: new_camera} new_bootstrap.cameras = {new_camera.id: new_camera}
mock_entry.api.bootstrap = new_bootstrap 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_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() await hass.async_block_till_done()
state = hass.states.get(camera[1]) 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) await remove_device(await hass_ws_client(hass), dead_device_entry.id, entry_id)
is True 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.core import HomeAssistant
from homeassistant.helpers import entity_registry as er 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") @pytest.fixture(name="light")
@ -36,9 +36,17 @@ async def light_fixture(
light_obj._api = mock_entry.api light_obj._api = mock_entry.api
light_obj.name = "Test Light" light_obj.name = "Test Light"
light_obj.is_light_on = False 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 = { mock_entry.api.bootstrap.lights = {
light_obj.id: light_obj, light_obj.id: light_obj,
no_light_obj.id: no_light_obj,
} }
await hass.config_entries.async_setup(mock_entry.entry.entry_id) 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.core import HomeAssistant
from homeassistant.helpers import entity_registry as er 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") @pytest.fixture(name="doorlock")
@ -39,9 +39,17 @@ async def doorlock_fixture(
lock_obj._api = mock_entry.api lock_obj._api = mock_entry.api
lock_obj.name = "Test Lock" lock_obj.name = "Test Lock"
lock_obj.lock_status = LockStatusType.OPEN 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 = { mock_entry.api.bootstrap.doorlocks = {
lock_obj.id: lock_obj, lock_obj.id: lock_obj,
no_lock_obj.id: no_lock_obj,
} }
await hass.config_entries.async_setup(mock_entry.entry.entry_id) 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.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er 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") @pytest.fixture(name="camera")
@ -45,9 +45,20 @@ async def camera_fixture(
camera_obj.channels[2]._api = mock_entry.api camera_obj.channels[2]._api = mock_entry.api
camera_obj.name = "Test Camera" camera_obj.name = "Test Camera"
camera_obj.feature_flags.has_speaker = True 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 = { mock_entry.api.bootstrap.cameras = {
camera_obj.id: camera_obj, camera_obj.id: camera_obj,
no_camera_obj.id: no_camera_obj,
} }
await hass.config_entries.async_setup(mock_entry.entry.entry_id) 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 unittest.mock import AsyncMock
from pyunifiprotect.data import Light 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.components.unifiprotect.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
@ -34,6 +36,10 @@ async def test_migrate_reboot_button(
light1.id: light1, light1.id: light1,
light2.id: light2, 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) mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap)
registry = er.async_get(hass) registry = er.async_get(hass)
@ -77,6 +83,41 @@ async def test_migrate_reboot_button(
assert light.unique_id == f"{light2.mac}_reboot" 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( async def test_migrate_reboot_button_no_device(
hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light
): ):
@ -132,6 +173,9 @@ async def test_migrate_reboot_button_fail(
mock_entry.api.bootstrap.lights = { mock_entry.api.bootstrap.lights = {
light1.id: light1, 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) mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap)
registry = er.async_get(hass) registry = er.async_get(hass)
@ -175,6 +219,9 @@ async def test_migrate_device_mac_button_fail(
mock_entry.api.bootstrap.lights = { mock_entry.api.bootstrap.lights = {
light1.id: light1, 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) mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap)
registry = er.async_get(hass) 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") 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}_reboot" 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