Automatically add newly added devices for UniFi Protect (#73879)

This commit is contained in:
Christopher Bailey 2022-06-27 17:03:25 -04:00 committed by GitHub
parent 33f5b225fb
commit b9c636ba4e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 696 additions and 76 deletions

View file

@ -11,6 +11,7 @@ from pyunifiprotect.data import (
Event,
Light,
MountType,
ProtectAdoptableDeviceModel,
ProtectModelWithId,
Sensor,
)
@ -23,10 +24,11 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .const import DISPATCH_ADOPT, DOMAIN
from .data import ProtectData
from .entity import (
EventThumbnailMixin,
@ -35,6 +37,7 @@ from .entity import (
async_all_device_entities,
)
from .models import PermRequired, ProtectRequiredKeysMixin
from .utils import async_dispatch_id as _ufpd
_LOGGER = logging.getLogger(__name__)
_KEY_DOOR = "door"
@ -364,6 +367,24 @@ async def async_setup_entry(
) -> None:
"""Set up binary sensors for UniFi Protect integration."""
data: ProtectData = hass.data[DOMAIN][entry.entry_id]
async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
entities: list[ProtectDeviceEntity] = async_all_device_entities(
data,
ProtectDeviceBinarySensor,
camera_descs=CAMERA_SENSORS,
light_descs=LIGHT_SENSORS,
sense_descs=SENSE_SENSORS,
lock_descs=DOORLOCK_SENSORS,
viewer_descs=VIEWER_SENSORS,
ufp_device=device,
)
if device.is_adopted and isinstance(device, Camera):
entities += _async_motion_entities(data, ufp_device=device)
async_add_entities(entities)
async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device)
entities: list[ProtectDeviceEntity] = async_all_device_entities(
data,
ProtectDeviceBinarySensor,
@ -382,10 +403,14 @@ async def async_setup_entry(
@callback
def _async_motion_entities(
data: ProtectData,
ufp_device: ProtectAdoptableDeviceModel | None = None,
) -> list[ProtectDeviceEntity]:
entities: list[ProtectDeviceEntity] = []
for device in data.api.bootstrap.cameras.values():
if not device.is_adopted_by_us:
devices = (
data.api.bootstrap.cameras.values() if ufp_device is None else [ufp_device]
)
for device in devices:
if not device.is_adopted:
continue
for description in MOTION_SENSORS:

View file

@ -13,12 +13,14 @@ from homeassistant.components.button import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .const import DISPATCH_ADOPT, DOMAIN
from .data import ProtectData
from .entity import ProtectDeviceEntity, async_all_device_entities
from .models import PermRequired, ProtectSetableKeysMixin, T
from .utils import async_dispatch_id as _ufpd
@dataclass
@ -79,6 +81,19 @@ async def async_setup_entry(
"""Discover devices on a UniFi Protect NVR."""
data: ProtectData = hass.data[DOMAIN][entry.entry_id]
async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
entities = async_all_device_entities(
data,
ProtectButton,
all_descs=ALL_DEVICE_BUTTONS,
chime_descs=CHIME_BUTTONS,
sense_descs=SENSOR_BUTTONS,
ufp_device=device,
)
async_add_entities(entities)
async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device)
entities: list[ProtectDeviceEntity] = async_all_device_entities(
data,
ProtectButton,
@ -86,7 +101,6 @@ async def async_setup_entry(
chime_descs=CHIME_BUTTONS,
sense_descs=SENSOR_BUTTONS,
)
async_add_entities(entities)

View file

@ -4,10 +4,10 @@ from __future__ import annotations
from collections.abc import Generator
import logging
from pyunifiprotect.api import ProtectApiClient
from pyunifiprotect.data import (
Camera as UFPCamera,
CameraChannel,
ProtectAdoptableDeviceModel,
ProtectModelWithId,
StateType,
)
@ -15,6 +15,7 @@ from pyunifiprotect.data import (
from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
@ -23,28 +24,39 @@ from .const import (
ATTR_FPS,
ATTR_HEIGHT,
ATTR_WIDTH,
DISPATCH_ADOPT,
DISPATCH_CHANNELS,
DOMAIN,
)
from .data import ProtectData
from .entity import ProtectDeviceEntity
from .utils import async_dispatch_id as _ufpd
_LOGGER = logging.getLogger(__name__)
def get_camera_channels(
protect: ProtectApiClient,
data: ProtectData,
ufp_device: UFPCamera | None = None,
) -> Generator[tuple[UFPCamera, CameraChannel, bool], None, None]:
"""Get all the camera channels."""
for camera in protect.bootstrap.cameras.values():
devices = (
data.api.bootstrap.cameras.values() if ufp_device is None else [ufp_device]
)
for camera in devices:
if not camera.is_adopted_by_us:
continue
if not camera.channels:
_LOGGER.warning(
"Camera does not have any channels: %s (id: %s)",
camera.display_name,
camera.id,
)
if ufp_device is None:
# only warn on startup
_LOGGER.warning(
"Camera does not have any channels: %s (id: %s)",
camera.display_name,
camera.id,
)
data.async_add_pending_camera_id(camera.id)
continue
is_default = True
@ -60,17 +72,12 @@ def get_camera_channels(
yield camera, camera.channels[0], True
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Discover cameras on a UniFi Protect NVR."""
data: ProtectData = hass.data[DOMAIN][entry.entry_id]
def _async_camera_entities(
data: ProtectData, ufp_device: UFPCamera | None = None
) -> list[ProtectDeviceEntity]:
disable_stream = data.disable_stream
entities = []
for camera, channel, is_default in get_camera_channels(data.api):
entities: list[ProtectDeviceEntity] = []
for camera, channel, is_default in get_camera_channels(data, ufp_device):
# do not enable streaming for package camera
# 2 FPS causes a lot of buferring
entities.append(
@ -95,6 +102,28 @@ async def async_setup_entry(
disable_stream,
)
)
return entities
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Discover cameras on a UniFi Protect NVR."""
data: ProtectData = hass.data[DOMAIN][entry.entry_id]
async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
if not isinstance(device, UFPCamera):
return
entities = _async_camera_entities(data, ufp_device=device)
async_add_entities(entities)
async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device)
async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_CHANNELS), _add_new_device)
entities = _async_camera_entities(data)
async_add_entities(entities)

View file

@ -60,3 +60,6 @@ PLATFORMS = [
Platform.SENSOR,
Platform.SWITCH,
]
DISPATCH_ADOPT = "adopt_device"
DISPATCH_CHANNELS = "new_camera_channels"

View file

@ -10,6 +10,7 @@ from pyunifiprotect import ProtectApiClient
from pyunifiprotect.data import (
Bootstrap,
Event,
EventType,
Liveview,
ModelType,
ProtectAdoptableDeviceModel,
@ -20,10 +21,22 @@ from pyunifiprotect.exceptions import ClientError, NotAuthorized
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from .const import CONF_DISABLE_RTSP, DEVICES_THAT_ADOPT, DEVICES_WITH_ENTITIES, DOMAIN
from .utils import async_get_devices, async_get_devices_by_type
from .const import (
CONF_DISABLE_RTSP,
DEVICES_THAT_ADOPT,
DEVICES_WITH_ENTITIES,
DISPATCH_ADOPT,
DISPATCH_CHANNELS,
DOMAIN,
)
from .utils import (
async_dispatch_id as _ufpd,
async_get_devices,
async_get_devices_by_type,
)
_LOGGER = logging.getLogger(__name__)
@ -56,6 +69,7 @@ class ProtectData:
self._hass = hass
self._update_interval = update_interval
self._subscriptions: dict[str, list[Callable[[ProtectModelWithId], None]]] = {}
self._pending_camera_ids: set[str] = set()
self._unsub_interval: CALLBACK_TYPE | None = None
self._unsub_websocket: CALLBACK_TYPE | None = None
@ -117,6 +131,18 @@ class ProtectData:
self.last_update_success = True
self._async_process_updates(updates)
@callback
def async_add_pending_camera_id(self, camera_id: str) -> None:
"""
Add pending camera.
A "pending camera" is one that has been adopted by not had its camera channels
initialized yet. Will cause Websocket code to check for channels to be
initialized for the camera and issue a dispatch once they do.
"""
self._pending_camera_ids.add(camera_id)
@callback
def _async_process_ws_message(self, message: WSSubscriptionMessage) -> None:
# removed packets are not processed yet
@ -125,8 +151,19 @@ class ProtectData:
):
return
if message.new_obj.model in DEVICES_WITH_ENTITIES:
self._async_signal_device_update(message.new_obj)
obj = message.new_obj
if obj.model in DEVICES_WITH_ENTITIES:
self._async_signal_device_update(obj)
if (
obj.model == ModelType.CAMERA
and obj.id in self._pending_camera_ids
and "channels" in message.changed_data
):
self._pending_camera_ids.remove(obj.id)
async_dispatcher_send(
self._hass, _ufpd(self._entry, DISPATCH_CHANNELS), obj
)
# trigger update for all Cameras with LCD screens when NVR Doorbell settings updates
if "doorbell_settings" in message.changed_data:
_LOGGER.debug(
@ -137,17 +174,25 @@ class ProtectData:
if camera.feature_flags.has_lcd_screen:
self._async_signal_device_update(camera)
# trigger updates for camera that the event references
elif isinstance(message.new_obj, Event):
if message.new_obj.camera is not None:
self._async_signal_device_update(message.new_obj.camera)
elif message.new_obj.light is not None:
self._async_signal_device_update(message.new_obj.light)
elif message.new_obj.sensor is not None:
self._async_signal_device_update(message.new_obj.sensor)
elif isinstance(obj, Event):
if obj.type == EventType.DEVICE_ADOPTED:
if obj.metadata is not None and obj.metadata.device_id is not None:
device = self.api.bootstrap.get_device_from_id(
obj.metadata.device_id
)
if device is not None:
_LOGGER.debug("New device detected: %s", device.id)
async_dispatcher_send(
self._hass, _ufpd(self._entry, DISPATCH_ADOPT), device
)
elif obj.camera is not None:
self._async_signal_device_update(obj.camera)
elif obj.light is not None:
self._async_signal_device_update(obj.light)
elif obj.sensor is not None:
self._async_signal_device_update(obj.sensor)
# alert user viewport needs restart so voice clients can get new options
elif len(self.api.bootstrap.viewers) > 0 and isinstance(
message.new_obj, Liveview
):
elif len(self.api.bootstrap.viewers) > 0 and isinstance(obj, Liveview):
_LOGGER.warning(
"Liveviews updated. Restart Home Assistant to update Viewport select options"
)

View file

@ -38,12 +38,16 @@ def _async_device_entities(
klass: type[ProtectDeviceEntity],
model_type: ModelType,
descs: Sequence[ProtectRequiredKeysMixin],
ufp_device: ProtectAdoptableDeviceModel | None = None,
) -> list[ProtectDeviceEntity]:
if len(descs) == 0:
return []
entities: list[ProtectDeviceEntity] = []
for device in data.get_by_types({model_type}):
devices = (
[ufp_device] if ufp_device is not None else data.get_by_types({model_type})
)
for device in devices:
if not device.is_adopted_by_us:
continue
@ -89,6 +93,7 @@ def async_all_device_entities(
lock_descs: Sequence[ProtectRequiredKeysMixin] | None = None,
chime_descs: Sequence[ProtectRequiredKeysMixin] | None = None,
all_descs: Sequence[ProtectRequiredKeysMixin] | None = None,
ufp_device: ProtectAdoptableDeviceModel | None = None,
) -> list[ProtectDeviceEntity]:
"""Generate a list of all the device entities."""
all_descs = list(all_descs or [])
@ -99,14 +104,33 @@ def async_all_device_entities(
lock_descs = list(lock_descs or []) + all_descs
chime_descs = list(chime_descs or []) + all_descs
return (
_async_device_entities(data, klass, ModelType.CAMERA, camera_descs)
+ _async_device_entities(data, klass, ModelType.LIGHT, light_descs)
+ _async_device_entities(data, klass, ModelType.SENSOR, sense_descs)
+ _async_device_entities(data, klass, ModelType.VIEWPORT, viewer_descs)
+ _async_device_entities(data, klass, ModelType.DOORLOCK, lock_descs)
+ _async_device_entities(data, klass, ModelType.CHIME, chime_descs)
)
if ufp_device is None:
return (
_async_device_entities(data, klass, ModelType.CAMERA, camera_descs)
+ _async_device_entities(data, klass, ModelType.LIGHT, light_descs)
+ _async_device_entities(data, klass, ModelType.SENSOR, sense_descs)
+ _async_device_entities(data, klass, ModelType.VIEWPORT, viewer_descs)
+ _async_device_entities(data, klass, ModelType.DOORLOCK, lock_descs)
+ _async_device_entities(data, klass, ModelType.CHIME, chime_descs)
)
descs = []
if ufp_device.model == ModelType.CAMERA:
descs = camera_descs
elif ufp_device.model == ModelType.LIGHT:
descs = light_descs
elif ufp_device.model == ModelType.SENSOR:
descs = sense_descs
elif ufp_device.model == ModelType.VIEWPORT:
descs = viewer_descs
elif ufp_device.model == ModelType.DOORLOCK:
descs = lock_descs
elif ufp_device.model == ModelType.CHIME:
descs = chime_descs
if len(descs) == 0 or ufp_device.model is None:
return []
return _async_device_entities(data, klass, ufp_device.model, descs, ufp_device)
class ProtectDeviceEntity(Entity):

View file

@ -4,16 +4,23 @@ from __future__ import annotations
import logging
from typing import Any
from pyunifiprotect.data import Light, ProtectModelWithId
from pyunifiprotect.data import (
Light,
ModelType,
ProtectAdoptableDeviceModel,
ProtectModelWithId,
)
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .const import DISPATCH_ADOPT, DOMAIN
from .data import ProtectData
from .entity import ProtectDeviceEntity
from .utils import async_dispatch_id as _ufpd
_LOGGER = logging.getLogger(__name__)
@ -25,6 +32,18 @@ async def async_setup_entry(
) -> None:
"""Set up lights for UniFi Protect integration."""
data: ProtectData = hass.data[DOMAIN][entry.entry_id]
async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
if not device.is_adopted_by_us:
return
if device.model == ModelType.LIGHT and device.can_write(
data.api.bootstrap.auth_user
):
async_add_entities([ProtectLight(data, device)])
async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device)
entities = []
for device in data.api.bootstrap.lights.values():
if not device.is_adopted_by_us:

View file

@ -4,16 +4,23 @@ from __future__ import annotations
import logging
from typing import Any
from pyunifiprotect.data import Doorlock, LockStatusType, ProtectModelWithId
from pyunifiprotect.data import (
Doorlock,
LockStatusType,
ProtectAdoptableDeviceModel,
ProtectModelWithId,
)
from homeassistant.components.lock import LockEntity, LockEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .const import DISPATCH_ADOPT, DOMAIN
from .data import ProtectData
from .entity import ProtectDeviceEntity
from .utils import async_dispatch_id as _ufpd
_LOGGER = logging.getLogger(__name__)
@ -26,6 +33,15 @@ async def async_setup_entry(
"""Set up locks on a UniFi Protect NVR."""
data: ProtectData = hass.data[DOMAIN][entry.entry_id]
async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
if not device.is_adopted_by_us:
return
if isinstance(device, Doorlock):
async_add_entities([ProtectLock(data, device)])
async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device)
entities = []
for device in data.api.bootstrap.doorlocks.values():
if not device.is_adopted_by_us:

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import logging
from typing import Any
from pyunifiprotect.data import Camera, ProtectModelWithId
from pyunifiprotect.data import Camera, ProtectAdoptableDeviceModel, ProtectModelWithId
from pyunifiprotect.exceptions import StreamError
from homeassistant.components import media_source
@ -23,11 +23,13 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_IDLE, STATE_PLAYING
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .const import DISPATCH_ADOPT, DOMAIN
from .data import ProtectData
from .entity import ProtectDeviceEntity
from .utils import async_dispatch_id as _ufpd
_LOGGER = logging.getLogger(__name__)
@ -40,12 +42,21 @@ async def async_setup_entry(
"""Discover cameras with speakers on a UniFi Protect NVR."""
data: ProtectData = hass.data[DOMAIN][entry.entry_id]
async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
if not device.is_adopted_by_us:
return
if isinstance(device, Camera) and device.feature_flags.has_speaker:
async_add_entities([ProtectMediaPlayer(data, device)])
async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device)
entities = []
for device in data.api.bootstrap.cameras.values():
if not device.is_adopted_by_us or not device.feature_flags.has_speaker:
if not device.is_adopted_by_us:
continue
entities.append(ProtectMediaPlayer(data, device))
if device.feature_flags.has_speaker:
entities.append(ProtectMediaPlayer(data, device))
async_add_entities(entities)

View file

@ -4,19 +4,27 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from pyunifiprotect.data import Camera, Doorlock, Light, ProtectModelWithId
from pyunifiprotect.data import (
Camera,
Doorlock,
Light,
ProtectAdoptableDeviceModel,
ProtectModelWithId,
)
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, TIME_SECONDS
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .const import DISPATCH_ADOPT, DOMAIN
from .data import ProtectData
from .entity import ProtectDeviceEntity, async_all_device_entities
from .models import PermRequired, ProtectSetableKeysMixin, T
from .utils import async_dispatch_id as _ufpd
@dataclass
@ -184,6 +192,22 @@ async def async_setup_entry(
) -> None:
"""Set up number entities for UniFi Protect integration."""
data: ProtectData = hass.data[DOMAIN][entry.entry_id]
async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
entities = async_all_device_entities(
data,
ProtectNumbers,
camera_descs=CAMERA_NUMBERS,
light_descs=LIGHT_NUMBERS,
sense_descs=SENSE_NUMBERS,
lock_descs=DOORLOCK_NUMBERS,
chime_descs=CHIME_NUMBERS,
ufp_device=device,
)
async_add_entities(entities)
async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device)
entities: list[ProtectDeviceEntity] = async_all_device_entities(
data,
ProtectNumbers,

View file

@ -19,6 +19,7 @@ from pyunifiprotect.data import (
LightModeEnableType,
LightModeType,
MountType,
ProtectAdoptableDeviceModel,
ProtectModelWithId,
RecordingMode,
Sensor,
@ -32,14 +33,15 @@ from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import EntityCategory
from homeassistant.util.dt import utcnow
from .const import ATTR_DURATION, ATTR_MESSAGE, DOMAIN, TYPE_EMPTY_VALUE
from .const import ATTR_DURATION, ATTR_MESSAGE, DISPATCH_ADOPT, DOMAIN, TYPE_EMPTY_VALUE
from .data import ProtectData
from .entity import ProtectDeviceEntity, async_all_device_entities
from .models import PermRequired, ProtectSetableKeysMixin, T
from .utils import async_get_light_motion_current
from .utils import async_dispatch_id as _ufpd, async_get_light_motion_current
_LOGGER = logging.getLogger(__name__)
_KEY_LIGHT_MOTION = "light_motion"
@ -320,6 +322,22 @@ async def async_setup_entry(
) -> None:
"""Set up number entities for UniFi Protect integration."""
data: ProtectData = hass.data[DOMAIN][entry.entry_id]
async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
entities = async_all_device_entities(
data,
ProtectSelects,
camera_descs=CAMERA_SELECTS,
light_descs=LIGHT_SELECTS,
sense_descs=SENSE_SELECTS,
viewer_descs=VIEWER_SELECTS,
lock_descs=DOORLOCK_SELECTS,
ufp_device=device,
)
async_add_entities(entities)
async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device)
entities: list[ProtectDeviceEntity] = async_all_device_entities(
data,
ProtectSelects,

View file

@ -36,10 +36,11 @@ from homeassistant.const import (
TIME_SECONDS,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .const import DISPATCH_ADOPT, DOMAIN
from .data import ProtectData
from .entity import (
EventThumbnailMixin,
@ -48,7 +49,7 @@ from .entity import (
async_all_device_entities,
)
from .models import PermRequired, ProtectRequiredKeysMixin, T
from .utils import async_get_light_motion_current
from .utils import async_dispatch_id as _ufpd, async_get_light_motion_current
_LOGGER = logging.getLogger(__name__)
OBJECT_TYPE_NONE = "none"
@ -594,6 +595,26 @@ async def async_setup_entry(
) -> None:
"""Set up sensors for UniFi Protect integration."""
data: ProtectData = hass.data[DOMAIN][entry.entry_id]
async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
entities = async_all_device_entities(
data,
ProtectDeviceSensor,
all_descs=ALL_DEVICES_SENSORS,
camera_descs=CAMERA_SENSORS + CAMERA_DISABLED_SENSORS,
sense_descs=SENSE_SENSORS,
light_descs=LIGHT_SENSORS,
lock_descs=DOORLOCK_SENSORS,
chime_descs=CHIME_SENSORS,
viewer_descs=VIEWER_SENSORS,
ufp_device=device,
)
if device.is_adopted_by_us and isinstance(device, Camera):
entities += _async_motion_entities(data, ufp_device=device)
async_add_entities(entities)
async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device)
entities: list[ProtectDeviceEntity] = async_all_device_entities(
data,
ProtectDeviceSensor,
@ -614,13 +635,17 @@ async def async_setup_entry(
@callback
def _async_motion_entities(
data: ProtectData,
ufp_device: Camera | None = None,
) -> list[ProtectDeviceEntity]:
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
devices = (
data.api.bootstrap.cameras.values() if ufp_device is None else [ufp_device]
)
for device in devices:
if not device.is_adopted_by_us:
continue
for description in MOTION_TRIP_SENSORS:
entities.append(ProtectDeviceSensor(data, device, description))
_LOGGER.debug(
"Adding trip sensor entity %s for %s",

View file

@ -15,13 +15,15 @@ from pyunifiprotect.data import (
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .const import DISPATCH_ADOPT, DOMAIN
from .data import ProtectData
from .entity import ProtectDeviceEntity, async_all_device_entities
from .models import PermRequired, ProtectSetableKeysMixin, T
from .utils import async_dispatch_id as _ufpd
_LOGGER = logging.getLogger(__name__)
@ -302,6 +304,22 @@ async def async_setup_entry(
) -> None:
"""Set up sensors for UniFi Protect integration."""
data: ProtectData = hass.data[DOMAIN][entry.entry_id]
async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
entities = async_all_device_entities(
data,
ProtectSwitch,
camera_descs=CAMERA_SWITCHES,
light_descs=LIGHT_SWITCHES,
sense_descs=SENSE_SWITCHES,
lock_descs=DOORLOCK_SWITCHES,
viewer_descs=VIEWER_SWITCHES,
ufp_device=device,
)
async_add_entities(entities)
async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device)
entities: list[ProtectDeviceEntity] = async_all_device_entities(
data,
ProtectSwitch,

View file

@ -15,9 +15,10 @@ from pyunifiprotect.data import (
ProtectAdoptableDeviceModel,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from .const import ModelType
from .const import DOMAIN, ModelType
def get_nested_attr(obj: Any, attr: str) -> Any:
@ -98,3 +99,10 @@ def async_get_light_motion_current(obj: Light) -> str:
):
return f"{LightModeType.MOTION.value}Dark"
return obj.light_mode_settings.mode.value
@callback
def async_dispatch_id(entry: ConfigEntry, dispatch: str) -> str:
"""Generate entry specific dispatch ID."""
return f"{DOMAIN}.{entry.entry_id}.{dispatch}"

View file

@ -32,15 +32,59 @@ from homeassistant.helpers import entity_registry as er
from .utils import (
MockUFPFixture,
adopt_devices,
assert_entity_counts,
ids_from_device_description,
init_entry,
remove_entities,
)
LIGHT_SENSOR_WRITE = LIGHT_SENSORS[:2]
SENSE_SENSORS_WRITE = SENSE_SENSORS[:4]
async def test_binary_sensor_camera_remove(
hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, unadopted_camera: Camera
):
"""Test removing and re-adding a camera device."""
ufp.api.bootstrap.nvr.system_info.ustorage = None
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.BINARY_SENSOR, 3, 3)
await remove_entities(hass, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.BINARY_SENSOR, 0, 0)
await adopt_devices(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.BINARY_SENSOR, 3, 3)
async def test_binary_sensor_light_remove(
hass: HomeAssistant, ufp: MockUFPFixture, light: Light
):
"""Test removing and re-adding a light device."""
ufp.api.bootstrap.nvr.system_info.ustorage = None
await init_entry(hass, ufp, [light])
assert_entity_counts(hass, Platform.BINARY_SENSOR, 2, 2)
await remove_entities(hass, [light])
assert_entity_counts(hass, Platform.BINARY_SENSOR, 0, 0)
await adopt_devices(hass, ufp, [light])
assert_entity_counts(hass, Platform.BINARY_SENSOR, 2, 2)
async def test_binary_sensor_sensor_remove(
hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor
):
"""Test removing and re-adding a light device."""
ufp.api.bootstrap.nvr.system_info.ustorage = None
await init_entry(hass, ufp, [sensor_all])
assert_entity_counts(hass, Platform.BINARY_SENSOR, 4, 4)
await remove_entities(hass, [sensor_all])
assert_entity_counts(hass, Platform.BINARY_SENSOR, 0, 0)
await adopt_devices(hass, ufp, [sensor_all])
assert_entity_counts(hass, Platform.BINARY_SENSOR, 4, 4)
async def test_binary_sensor_setup_light(
hass: HomeAssistant, ufp: MockUFPFixture, light: Light
):

View file

@ -11,7 +11,27 @@ from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .utils import MockUFPFixture, assert_entity_counts, enable_entity, init_entry
from .utils import (
MockUFPFixture,
adopt_devices,
assert_entity_counts,
enable_entity,
init_entry,
remove_entities,
)
async def test_button_chime_remove(
hass: HomeAssistant, ufp: MockUFPFixture, chime: Chime
):
"""Test removing and re-adding a light device."""
await init_entry(hass, ufp, [chime])
assert_entity_counts(hass, Platform.BUTTON, 3, 2)
await remove_entities(hass, [chime])
assert_entity_counts(hass, Platform.BUTTON, 0, 0)
await adopt_devices(hass, ufp, [chime])
assert_entity_counts(hass, Platform.BUTTON, 3, 2)
async def test_reboot_button(

View file

@ -33,9 +33,11 @@ from homeassistant.setup import async_setup_component
from .utils import (
MockUFPFixture,
adopt_devices,
assert_entity_counts,
enable_entity,
init_entry,
remove_entities,
time_changed,
)
@ -268,18 +270,37 @@ async def test_basic_setup(
await validate_no_stream_camera_state(hass, doorbell, 3, entity_id, features=0)
async def test_missing_channels(
hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera
):
async def test_adopt(hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera):
"""Test setting up camera with no camera channels."""
camera1 = camera.copy()
camera1.channels = []
await init_entry(hass, ufp, [camera1])
assert_entity_counts(hass, Platform.CAMERA, 0, 0)
await remove_entities(hass, [camera1])
assert_entity_counts(hass, Platform.CAMERA, 0, 0)
camera1.channels = []
await adopt_devices(hass, ufp, [camera1])
assert_entity_counts(hass, Platform.CAMERA, 0, 0)
camera1.channels = camera.channels
for channel in camera1.channels:
channel._api = ufp.api
mock_msg = Mock()
mock_msg.changed_data = {"channels": camera.channels}
mock_msg.new_obj = camera1
ufp.ws_msg(mock_msg)
await hass.async_block_till_done()
assert_entity_counts(hass, Platform.CAMERA, 2, 1)
await remove_entities(hass, [camera1])
assert_entity_counts(hass, Platform.CAMERA, 0, 0)
await adopt_devices(hass, ufp, [camera1])
assert_entity_counts(hass, Platform.CAMERA, 2, 1)
async def test_camera_image(
hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera

View file

@ -19,7 +19,24 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .utils import MockUFPFixture, assert_entity_counts, init_entry
from .utils import (
MockUFPFixture,
adopt_devices,
assert_entity_counts,
init_entry,
remove_entities,
)
async def test_light_remove(hass: HomeAssistant, ufp: MockUFPFixture, light: Light):
"""Test removing and re-adding a light device."""
await init_entry(hass, ufp, [light])
assert_entity_counts(hass, Platform.LIGHT, 1, 1)
await remove_entities(hass, [light])
assert_entity_counts(hass, Platform.LIGHT, 0, 0)
await adopt_devices(hass, ufp, [light])
assert_entity_counts(hass, Platform.LIGHT, 1, 1)
async def test_light_setup(

View file

@ -21,7 +21,26 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .utils import MockUFPFixture, assert_entity_counts, init_entry
from .utils import (
MockUFPFixture,
adopt_devices,
assert_entity_counts,
init_entry,
remove_entities,
)
async def test_lock_remove(
hass: HomeAssistant, ufp: MockUFPFixture, doorlock: Doorlock
):
"""Test removing and re-adding a lock device."""
await init_entry(hass, ufp, [doorlock])
assert_entity_counts(hass, Platform.LOCK, 1, 1)
await remove_entities(hass, [doorlock])
assert_entity_counts(hass, Platform.LOCK, 0, 0)
await adopt_devices(hass, ufp, [doorlock])
assert_entity_counts(hass, Platform.LOCK, 1, 1)
async def test_lock_setup(

View file

@ -25,7 +25,26 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from .utils import MockUFPFixture, assert_entity_counts, init_entry
from .utils import (
MockUFPFixture,
adopt_devices,
assert_entity_counts,
init_entry,
remove_entities,
)
async def test_media_player_camera_remove(
hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera
):
"""Test removing and re-adding a light device."""
await init_entry(hass, ufp, [doorbell])
assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1)
await remove_entities(hass, [doorbell])
assert_entity_counts(hass, Platform.MEDIA_PLAYER, 0, 0)
await adopt_devices(hass, ufp, [doorbell])
assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1)
async def test_media_player_setup(

View file

@ -21,12 +21,53 @@ from homeassistant.helpers import entity_registry as er
from .utils import (
MockUFPFixture,
adopt_devices,
assert_entity_counts,
ids_from_device_description,
init_entry,
remove_entities,
)
async def test_number_sensor_camera_remove(
hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera, unadopted_camera: Camera
):
"""Test removing and re-adding a camera device."""
await init_entry(hass, ufp, [camera, unadopted_camera])
assert_entity_counts(hass, Platform.NUMBER, 3, 3)
await remove_entities(hass, [camera, unadopted_camera])
assert_entity_counts(hass, Platform.NUMBER, 0, 0)
await adopt_devices(hass, ufp, [camera, unadopted_camera])
assert_entity_counts(hass, Platform.NUMBER, 3, 3)
async def test_number_sensor_light_remove(
hass: HomeAssistant, ufp: MockUFPFixture, light: Light
):
"""Test removing and re-adding a light device."""
await init_entry(hass, ufp, [light])
assert_entity_counts(hass, Platform.NUMBER, 2, 2)
await remove_entities(hass, [light])
assert_entity_counts(hass, Platform.NUMBER, 0, 0)
await adopt_devices(hass, ufp, [light])
assert_entity_counts(hass, Platform.NUMBER, 2, 2)
async def test_number_lock_remove(
hass: HomeAssistant, ufp: MockUFPFixture, doorlock: Doorlock
):
"""Test removing and re-adding a light device."""
await init_entry(hass, ufp, [doorlock])
assert_entity_counts(hass, Platform.NUMBER, 1, 1)
await remove_entities(hass, [doorlock])
assert_entity_counts(hass, Platform.NUMBER, 0, 0)
await adopt_devices(hass, ufp, [doorlock])
assert_entity_counts(hass, Platform.NUMBER, 1, 1)
async def test_number_setup_light(
hass: HomeAssistant, ufp: MockUFPFixture, light: Light
):

View file

@ -41,12 +41,56 @@ from homeassistant.helpers import entity_registry as er
from .utils import (
MockUFPFixture,
adopt_devices,
assert_entity_counts,
ids_from_device_description,
init_entry,
remove_entities,
)
async def test_select_camera_remove(
hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, unadopted_camera: Camera
):
"""Test removing and re-adding a camera device."""
ufp.api.bootstrap.nvr.system_info.ustorage = None
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.SELECT, 4, 4)
await remove_entities(hass, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.SELECT, 0, 0)
await adopt_devices(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.SELECT, 4, 4)
async def test_select_light_remove(
hass: HomeAssistant, ufp: MockUFPFixture, light: Light
):
"""Test removing and re-adding a light device."""
ufp.api.bootstrap.nvr.system_info.ustorage = None
await init_entry(hass, ufp, [light])
assert_entity_counts(hass, Platform.SELECT, 2, 2)
await remove_entities(hass, [light])
assert_entity_counts(hass, Platform.SELECT, 0, 0)
await adopt_devices(hass, ufp, [light])
assert_entity_counts(hass, Platform.SELECT, 2, 2)
async def test_select_viewer_remove(
hass: HomeAssistant, ufp: MockUFPFixture, viewer: Viewer
):
"""Test removing and re-adding a light device."""
ufp.api.bootstrap.nvr.system_info.ustorage = None
await init_entry(hass, ufp, [viewer])
assert_entity_counts(hass, Platform.SELECT, 1, 1)
await remove_entities(hass, [viewer])
assert_entity_counts(hass, Platform.SELECT, 0, 0)
await adopt_devices(hass, ufp, [viewer])
assert_entity_counts(hass, Platform.SELECT, 1, 1)
async def test_select_setup_light(
hass: HomeAssistant, ufp: MockUFPFixture, light: Light
):

View file

@ -41,10 +41,12 @@ from homeassistant.helpers import entity_registry as er
from .utils import (
MockUFPFixture,
adopt_devices,
assert_entity_counts,
enable_entity,
ids_from_device_description,
init_entry,
remove_entities,
reset_objects,
time_changed,
)
@ -53,6 +55,34 @@ CAMERA_SENSORS_WRITE = CAMERA_SENSORS[:5]
SENSE_SENSORS_WRITE = SENSE_SENSORS[:8]
async def test_sensor_camera_remove(
hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, unadopted_camera: Camera
):
"""Test removing and re-adding a camera device."""
ufp.api.bootstrap.nvr.system_info.ustorage = None
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.SENSOR, 25, 13)
await remove_entities(hass, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.SENSOR, 12, 9)
await adopt_devices(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.SENSOR, 25, 13)
async def test_sensor_sensor_remove(
hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor
):
"""Test removing and re-adding a light device."""
ufp.api.bootstrap.nvr.system_info.ustorage = None
await init_entry(hass, ufp, [sensor_all])
assert_entity_counts(hass, Platform.SENSOR, 22, 14)
await remove_entities(hass, [sensor_all])
assert_entity_counts(hass, Platform.SENSOR, 12, 9)
await adopt_devices(hass, ufp, [sensor_all])
assert_entity_counts(hass, Platform.SENSOR, 22, 14)
async def test_sensor_setup_sensor(
hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor
):

View file

@ -19,10 +19,12 @@ from homeassistant.helpers import entity_registry as er
from .utils import (
MockUFPFixture,
adopt_devices,
assert_entity_counts,
enable_entity,
ids_from_device_description,
init_entry,
remove_entities,
)
CAMERA_SWITCHES_BASIC = [
@ -37,6 +39,34 @@ CAMERA_SWITCHES_NO_EXTRA = [
]
async def test_switch_camera_remove(
hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, unadopted_camera: Camera
):
"""Test removing and re-adding a camera device."""
ufp.api.bootstrap.nvr.system_info.ustorage = None
await init_entry(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.SWITCH, 13, 12)
await remove_entities(hass, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.SWITCH, 0, 0)
await adopt_devices(hass, ufp, [doorbell, unadopted_camera])
assert_entity_counts(hass, Platform.SWITCH, 13, 12)
async def test_switch_light_remove(
hass: HomeAssistant, ufp: MockUFPFixture, light: Light
):
"""Test removing and re-adding a light device."""
ufp.api.bootstrap.nvr.system_info.ustorage = None
await init_entry(hass, ufp, [light])
assert_entity_counts(hass, Platform.SWITCH, 2, 1)
await remove_entities(hass, [light])
assert_entity_counts(hass, Platform.SWITCH, 0, 0)
await adopt_devices(hass, ufp, [light])
assert_entity_counts(hass, Platform.SWITCH, 2, 1)
async def test_switch_setup_no_perm(
hass: HomeAssistant,
ufp: MockUFPFixture,

View file

@ -5,11 +5,15 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from typing import Any, Callable, Sequence
from unittest.mock import Mock
from pyunifiprotect import ProtectApiClient
from pyunifiprotect.data import (
Bootstrap,
Camera,
Event,
EventType,
ModelType,
ProtectAdoptableDeviceModel,
WSSubscriptionMessage,
)
@ -18,7 +22,7 @@ from pyunifiprotect.test_util.anonymize import random_hex
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, split_entity_id
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.entity import EntityDescription
import homeassistant.util.dt as dt_util
@ -166,3 +170,55 @@ async def init_entry(
await hass.config_entries.async_setup(ufp.entry.entry_id)
await hass.async_block_till_done()
async def remove_entities(
hass: HomeAssistant,
ufp_devices: list[ProtectAdoptableDeviceModel],
) -> None:
"""Remove all entities for given Protect devices."""
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
for ufp_device in ufp_devices:
if not ufp_device.is_adopted_by_us:
continue
name = ufp_device.display_name.replace(" ", "_").lower()
entity = entity_registry.async_get(f"{Platform.SENSOR}.{name}_uptime")
assert entity is not None
device_id = entity.device_id
for reg in list(entity_registry.entities.values()):
if reg.device_id == device_id:
entity_registry.async_remove(reg.entity_id)
device_registry.async_remove_device(device_id)
await hass.async_block_till_done()
async def adopt_devices(
hass: HomeAssistant,
ufp: MockUFPFixture,
ufp_devices: list[ProtectAdoptableDeviceModel],
):
"""Emit WS to re-adopt give Protect devices."""
for ufp_device in ufp_devices:
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.new_obj = Event(
api=ufp_device.api,
id=random_hex(24),
smart_detect_types=[],
smart_detect_event_ids=[],
type=EventType.DEVICE_ADOPTED,
start=dt_util.utcnow(),
score=100,
metadata={"device_id": ufp_device.id},
model=ModelType.EVENT,
)
ufp.ws_msg(mock_msg)
await hass.async_block_till_done()