diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 88ae7da658cc..d6891db8aac2 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -12,6 +12,7 @@ from uiprotect.data import ( MountType, ProtectAdoptableDeviceModel, Sensor, + SmartDetectObjectType, ) from uiprotect.data.nvr import UOSDisk from uiprotect.data.public_devices import ( @@ -181,7 +182,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( key="smart_person", translation_key="detections_person", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_person", + ufp_capability=SmartDetectObjectType.PERSON, ufp_value="is_person_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -189,7 +190,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( key="smart_vehicle", translation_key="detections_vehicle", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_vehicle", + ufp_capability=SmartDetectObjectType.VEHICLE, ufp_value="is_vehicle_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -197,7 +198,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( key="smart_animal", translation_key="detections_animal", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_animal", + ufp_capability=SmartDetectObjectType.ANIMAL, ufp_value="is_animal_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -205,7 +206,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( key="smart_package", translation_key="detections_package", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_package", + ufp_capability=SmartDetectObjectType.PACKAGE, ufp_value="is_package_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -213,7 +214,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( key="smart_licenseplate", translation_key="detections_license_plate", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_license_plate", + ufp_capability=SmartDetectObjectType.LICENSE_PLATE, ufp_value="is_license_plate_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -221,7 +222,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( key="smart_smoke", translation_key="detections_smoke", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_smoke", + ufp_capability=SmartDetectObjectType.SMOKE, ufp_value="is_smoke_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -229,7 +230,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( key="smart_cmonx", translation_key="detections_co_alarm", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_co", + ufp_capability=SmartDetectObjectType.CMONX, ufp_value="is_co_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -237,7 +238,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( key="smart_siren", translation_key="detections_siren", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_siren", + ufp_capability=SmartDetectObjectType.SIREN, ufp_value="is_siren_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -245,7 +246,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( key="smart_baby_cry", translation_key="detections_baby_cry", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_baby_cry", + ufp_capability=SmartDetectObjectType.BABY_CRY, ufp_value="is_baby_cry_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -253,7 +254,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( key="smart_speak", translation_key="detections_speaking", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_speaking", + ufp_capability=SmartDetectObjectType.SPEAK, ufp_value="is_speaking_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -261,7 +262,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( key="smart_bark", translation_key="detections_barking", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_bark", + ufp_capability=SmartDetectObjectType.BARK, ufp_value="is_bark_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -269,7 +270,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( key="smart_car_alarm", translation_key="detections_car_alarm", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_car_alarm", + ufp_capability=SmartDetectObjectType.BURGLAR, ufp_value="is_car_alarm_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -277,7 +278,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( key="smart_car_horn", translation_key="detections_car_horn", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_car_horn", + ufp_capability=SmartDetectObjectType.CAR_HORN, ufp_value="is_car_horn_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -285,7 +286,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( key="smart_glass_break", translation_key="detections_glass_break", entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_glass_break", + ufp_capability=SmartDetectObjectType.GLASS_BREAK, ufp_value="is_glass_break_detection_on", ufp_perm=PermRequired.NO_WRITE, ), @@ -316,7 +317,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_obj_person", translation_key="person_detected", - ufp_required_field="can_detect_person", + ufp_capability=SmartDetectObjectType.PERSON, ufp_public_value="is_person_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_person_detection_on"), @@ -324,7 +325,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_obj_vehicle", translation_key="vehicle_detected", - ufp_required_field="can_detect_vehicle", + ufp_capability=SmartDetectObjectType.VEHICLE, ufp_public_value="is_vehicle_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_vehicle_detection_on"), @@ -332,7 +333,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_obj_animal", translation_key="animal_detected", - ufp_required_field="can_detect_animal", + ufp_capability=SmartDetectObjectType.ANIMAL, ufp_public_value="is_animal_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_animal_detection_on"), @@ -348,7 +349,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_audio_smoke", translation_key="smoke_alarm_detected", - ufp_required_field="can_detect_smoke", + ufp_capability=SmartDetectObjectType.SMOKE, ufp_public_value="is_smoke_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_smoke_detection_on"), @@ -357,7 +358,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( key="smart_audio_cmonx", translation_key="co_alarm_detected", device_class=BinarySensorDeviceClass.CO, - ufp_required_field="can_detect_co", + ufp_capability=SmartDetectObjectType.CMONX, ufp_public_value="is_cmonx_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_co_detection_on"), @@ -365,7 +366,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_audio_siren", translation_key="siren_detected", - ufp_required_field="can_detect_siren", + ufp_capability=SmartDetectObjectType.SIREN, ufp_public_value="is_siren_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_siren_detection_on"), @@ -373,7 +374,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_audio_baby_cry", translation_key="baby_cry_detected", - ufp_required_field="can_detect_baby_cry", + ufp_capability=SmartDetectObjectType.BABY_CRY, ufp_public_value="is_baby_cry_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_baby_cry_detection_on"), @@ -381,7 +382,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_audio_speak", translation_key="speaking_detected", - ufp_required_field="can_detect_speaking", + ufp_capability=SmartDetectObjectType.SPEAK, ufp_public_value="is_speaking_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_speaking_detection_on"), @@ -389,7 +390,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_audio_bark", translation_key="barking_detected", - ufp_required_field="can_detect_bark", + ufp_capability=SmartDetectObjectType.BARK, ufp_public_value="is_bark_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_bark_detection_on"), @@ -397,7 +398,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_audio_car_alarm", translation_key="car_alarm_detected", - ufp_required_field="can_detect_car_alarm", + ufp_capability=SmartDetectObjectType.BURGLAR, ufp_public_value="is_car_alarm_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_car_alarm_detection_on"), @@ -405,7 +406,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_audio_car_horn", translation_key="car_horn_detected", - ufp_required_field="can_detect_car_horn", + ufp_capability=SmartDetectObjectType.CAR_HORN, ufp_public_value="is_car_horn_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_car_horn_detection_on"), @@ -413,7 +414,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="smart_audio_glass_break", translation_key="glass_break_detected", - ufp_required_field="can_detect_glass_break", + ufp_capability=SmartDetectObjectType.GLASS_BREAK, ufp_public_value="is_glass_break_currently_detected", ufp_event_driven=True, ufp_public_enabled_fn=operator.attrgetter("is_glass_break_detection_on"), diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index b716da2d761b..339965075f8e 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -19,12 +19,7 @@ from uiprotect.data.public_devices import PublicCamera from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - device_registry as dr, - entity_platform, - issue_registry as ir, -) -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers import entity_platform, issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity @@ -35,7 +30,6 @@ from .const import ( ATTR_FPS, ATTR_HEIGHT, ATTR_WIDTH, - DEFAULT_BRAND, DOMAIN, ) from .data import ProtectData, ProtectDeviceType, UFPConfigEntry @@ -303,26 +297,6 @@ class ProtectCamera(ProtectDeviceEntity, Camera): self._attr_supported_features = _ENABLE_FEATURE if source else _DISABLE_FEATURE self._stream_source = source - @callback - @override - def _async_set_device_info(self) -> None: - if self._private is not None: - super()._async_set_device_info() - return - # public-only: no market_name/firmware_version/protect_url, so device - # identity is limited. The NVR link uses the device id registered at - # setup — an API-key-only client has no private bootstrap to read the - # NVR mac from. - public = self._public - self._attr_device_info = DeviceInfo( - name=public.display_name, - model=public.type, - model_id=public.type, - manufacturer=DEFAULT_BRAND, - connections={(dr.CONNECTION_NETWORK_MAC, public.mac)}, - via_device_id=self.data.nvr_device_id, - ) - @callback @override def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index 03927d100fa9..60466d9e8913 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -90,6 +90,7 @@ PUBLIC_ONLY_PLATFORMS = [ Platform.EVENT, Platform.LIGHT, Platform.SENSOR, + Platform.SWITCH, ] # Stored local-user credentials do not imply the mode: they are kept on a diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 7d9c2cc86b5a..cca42c38ed62 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -80,27 +80,38 @@ def _async_dispatch_id(entry: UFPConfigEntry, dispatch: str) -> str: return f"{DOMAIN}.{entry.entry_id}.{dispatch}" +# Device families the public API alone provides; the private bootstrap has no +# store for them, so hybrid has no adopt path either and their add always goes +# through the public add signal. +_PUBLIC_ONLY_MODELS = {ModelType.FOB, ModelType.RELAY, ModelType.SIREN} + + def _pair_public_private[ PublicDeviceT: PublicDeviceModel, PrivateDeviceT: ProtectAdoptableDeviceModel, ]( public_devices: dict[str, PublicDeviceT], private_devices: dict[str, PrivateDeviceT], + *, + ignore_unadopted: bool = True, ) -> Generator[tuple[PublicDeviceT | None, PrivateDeviceT | None]]: """Pair public-master devices with their private fill by shared id. The public map is the master list; the matching private device is attached when present (hybrid) and ``None`` in public-only mode. An adopted private device not (yet) mirrored publicly is yielded as ``(None, private)`` so the - caller can defer it. Devices not adopted by us are skipped on both sides. + caller can defer it. Devices not adopted by us are skipped on both sides + unless ``ignore_unadopted`` is false (the adopt button needs them). """ for device_id, public in public_devices.items(): private = private_devices.get(device_id) - if private is not None and not private.is_adopted_by_us: + if ignore_unadopted and private is not None and not private.is_adopted_by_us: continue yield public, private for device_id, private in private_devices.items(): - if device_id in public_devices or not private.is_adopted_by_us: + if device_id in public_devices or ( + ignore_unadopted and not private.is_adopted_by_us + ): continue yield None, private @@ -198,6 +209,32 @@ class ProtectData: Generator[Camera], self.get_by_types({ModelType.CAMERA}, ignore_unadopted) ) + def get_public_devices( + self, model_type: ModelType, *, ignore_unadopted: bool = True + ) -> Generator[tuple[PublicDeviceModel | None, ProtectAdoptableDeviceModel | None]]: + """Yield ``(public, private)`` pairs of a model type (see _pair_public_private). + + Without a public bootstrap every private device is yielded unpaired, so + hybrid enumeration is unchanged when the public API is unavailable. + """ + api = self.api + public_devices: dict[str, PublicDeviceModel] = {} + if ( + api.has_public_bootstrap + and (store := api.public_bootstrap.store_for(model_type)) is not None + ): + public_devices = cast(dict[str, PublicDeviceModel], store) + # An API-key-only client never initializes the private bootstrap; + # accessing it would raise. + private_devices: dict[str, ProtectAdoptableDeviceModel] = ( + {} + if api.is_public_only + else async_get_devices_by_type(api.bootstrap, model_type) + ) + yield from _pair_public_private( + public_devices, private_devices, ignore_unadopted=ignore_unadopted + ) + def get_public_cameras( self, ) -> Generator[tuple[PublicCamera | None, Camera | None]]: @@ -299,20 +336,21 @@ class ProtectData: The first successful refresh fixes the add-dedup baseline: platforms enumerate that snapshot at setup, so only devices appearing later are - offered through the add signal. Public-only mode only, matching the - dispatch gate: hybrid never dispatches adds. + offered through the add signal. Covers the same devices as the + dispatch gate (see _async_uses_public_add). """ await self.api.update_public() if self._public_baseline_taken: return self._public_baseline_taken = True api = self.api - if not api.is_public_only or not api.has_public_bootstrap: + if not api.has_public_bootstrap: return self._known_public_macs.update( device.mac for device in api.public_bootstrap.all_devices() if isinstance(device, PublicDeviceModel) + and self._async_uses_public_add(device) ) @callback @@ -326,20 +364,28 @@ class ProtectData: self._public_baseline_taken = False self._known_public_macs.clear() + @callback + def _async_uses_public_add(self, device: PublicDeviceModel) -> bool: + """Whether a device is discovered through the public add signal. + + Every device in public-only mode. Hybrid discovers through the private + adopt path, where a second add would clash on unique_id, so only the + families without a private counterpart qualify there. + """ + return self.api.is_public_only or device.model in _PUBLIC_ONLY_MODELS + @callback def _async_dispatch_new_public_device(self, device: PublicDeviceModel) -> None: """Offer a public device to the platforms, once per mac. - Public-only mode only: hybrid discovers new devices through the private - adopt path, and a second add would clash on unique_id. Cameras are - excluded, the channels signal owns their (re-)enumeration. Dedup - happens here so platforms can add without their own duplicate checks. + Cameras are offered too: the channels signal only serves the camera + platform. Dedup happens here so platforms can add without their own + duplicate checks. """ api = self.api if ( - not api.is_public_only - or not api.has_public_bootstrap - or device.model is ModelType.CAMERA + not api.has_public_bootstrap + or not self._async_uses_public_add(device) or device.mac in self._known_public_macs ): return @@ -374,7 +420,7 @@ class ProtectData: if isinstance(new_obj, PublicDeviceModel): if new_obj.model is ModelType.CAMERA: self._async_reenumerate_camera_on_public_change(new_obj, message) - elif message.action is WSAction.ADD: + if message.action is WSAction.ADD: self._async_dispatch_new_public_device(new_obj) self._async_signal_public_update(new_obj.mac, new_obj) @@ -486,10 +532,9 @@ class ProtectData: if self.api.has_public_bootstrap: for public in list(self.api.public_bootstrap.cameras.values()): async_dispatcher_send(self._hass, self.channels_signal, public) - if self.api.is_public_only: - for device in list(self.api.public_bootstrap.all_devices()): - if isinstance(device, PublicDeviceModel): - self._async_dispatch_new_public_device(device) + for device in list(self.api.public_bootstrap.all_devices()): + if isinstance(device, PublicDeviceModel): + self._async_dispatch_new_public_device(device) @callback def _async_signal_nvr_update(self) -> None: diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 8f1bba6124ff..8ed493caed63 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -1,17 +1,23 @@ """Shared Entity definition for UniFi Protect Integration.""" -from collections.abc import Callable, Coroutine, Sequence +from collections.abc import Callable, Coroutine, Iterable, Sequence from dataclasses import dataclass from datetime import datetime from enum import Enum from functools import partial import logging from operator import attrgetter -from typing import TYPE_CHECKING, Any, Generic, TypeVar, override +from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast, override -from uiprotect import make_enabled_getter, make_required_getter, make_value_getter +from uiprotect import ( + get_nested_attr_as_bool, + make_enabled_getter, + make_required_getter, + make_value_getter, +) from uiprotect.data import ( NVR, + Camera, DeviceState, Event, Fob, @@ -21,10 +27,15 @@ from uiprotect.data import ( SmartDetectObjectType, StateType, ) -from uiprotect.data.public_devices import PublicSensor, SensorFeatureCapability +from uiprotect.data.public_devices import ( + PublicCamera, + PublicSensor, + SensorFeatureCapability, +) from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription @@ -54,14 +65,23 @@ class PermRequired(int, Enum): @callback def _async_capability_supported( - data: ProtectData, - device: ProtectAdoptableDeviceModel, + public: PublicDeviceModel | None, + private: ProtectAdoptableDeviceModel | None, description: ProtectEntityDescription, ) -> bool: - """Whether the device advertises the description's required sensor capability.""" + """Whether the device advertises the description's required capability. + + Smart-detect capabilities are answered by the master object (the private + camera in hybrid, the public one otherwise). Sensor capabilities come from + the public capability map; without one every description is created. + """ if (capability := description.ufp_capability) is None: return True - public = data.async_get_public_device(device) + if isinstance(capability, SmartDetectObjectType): + camera = cast( + "Camera | PublicCamera", private if private is not None else public + ) + return camera.can_detect(capability) if not isinstance(public, PublicSensor) or not public.has_feature_flags: return True return public.supports(capability) @@ -80,18 +100,63 @@ def async_remove_unsupported_sense_entities( upgrade then drops the never-functional entities created before the map existed. """ entity_registry = er.async_get(hass) - for device in data.get_by_types({ModelType.SENSOR}): + is_public_only = data.api.is_public_only + for public, private in data.get_public_devices(ModelType.SENSOR): + if private is not None: + mac = private.mac + elif is_public_only and public is not None: + mac = public.mac + else: + # Hybrid: not enumerated until the private fill arrives. + continue for description in descs: if description.ufp_capability is None or _async_capability_supported( - data, device, description + public, private, description ): continue if entity_id := entity_registry.async_get_entity_id( - platform, DOMAIN, f"{device.mac}_{description.key}" + platform, DOMAIN, f"{mac}_{description.key}" ): entity_registry.async_remove(entity_id) +@callback +def _async_public_only_entities( + data: ProtectData, + klass: type[BaseProtectEntity], + public: PublicDeviceModel, + descs: Sequence[ProtectEntityDescription], +) -> list[BaseProtectEntity]: + """Build the entities a public device supports without a private fill. + + Only descriptions reading a public value qualify; the required field and + the capability are checked against the public object. The public API has + no permission model, so ``ufp_perm`` does not apply. + """ + entities: list[BaseProtectEntity] = [] + for description in descs: + if ( + not description.is_public_value + or not description.has_required_public(public) + or not _async_capability_supported(public, None, description) + ): + continue + entities.append( + klass( + data, + device=cast(ProtectDeviceType, public), + description=description, + ) + ) + _LOGGER.debug( + "Adding %s entity %s for %s", + klass.__name__, + description.key, + public.display_name, + ) + return entities + + @callback def _async_device_entities( data: ProtectData, @@ -100,20 +165,31 @@ def _async_device_entities( descs: Sequence[ProtectEntityDescription], unadopted_descs: Sequence[ProtectEntityDescription] | None = None, ufp_device: ProtectAdoptableDeviceModel | None = None, + public_device: PublicDeviceModel | None = None, ) -> list[BaseProtectEntity]: if not descs and not unadopted_descs: return [] + pairs: Iterable[tuple[PublicDeviceModel | None, ProtectAdoptableDeviceModel | None]] + if ufp_device is not None: + pairs = [(data.async_get_public_device(ufp_device), ufp_device)] + elif public_device is not None: + pairs = [(public_device, None)] + else: + pairs = data.get_public_devices(model_type, ignore_unadopted=False) + + api = data.api + is_public_only = api.is_public_only + auth_user = None if is_public_only else api.bootstrap.auth_user entities: list[BaseProtectEntity] = [] - devices = ( - [ufp_device] - if ufp_device is not None - else data.get_by_types({model_type}, ignore_unadopted=False) - ) - auth_user = data.api.bootstrap.auth_user - for device in devices: + for public, device in pairs: + if device is None: + # Hybrid defers a device without private fill to the adopt dispatch. + if is_public_only and public is not None: + entities.extend(_async_public_only_entities(data, klass, public, descs)) + continue if TYPE_CHECKING: - assert isinstance(device, ProtectAdoptableDeviceModel) + assert auth_user is not None if not device.is_adopted_by_us: if unadopted_descs: for description in unadopted_descs: @@ -145,7 +221,7 @@ def _async_device_entities( if not description.has_required(device): continue - if not _async_capability_supported(data, device, description): + if not _async_capability_supported(public, device, description): continue entities.append( @@ -196,9 +272,15 @@ def async_all_device_entities( all_descs: Sequence[ProtectEntityDescription] | None = None, unadopted_descs: list[ProtectEntityDescription] | None = None, ufp_device: ProtectAdoptableDeviceModel | None = None, + public_device: PublicDeviceModel | None = None, ) -> list[BaseProtectEntity]: - """Generate a list of all the device entities.""" - if ufp_device is None: + """Generate a list of all the device entities. + + ``ufp_device`` builds for one adopted private device, ``public_device`` for + one public device without private fill (public-only mode). + """ + device = ufp_device if ufp_device is not None else public_device + if device is None: entities: list[BaseProtectEntity] = [] for model_type in _ALL_MODEL_TYPES: descs = _combine_model_descs(model_type, model_descriptions, all_descs) @@ -207,7 +289,7 @@ def async_all_device_entities( ) return entities - device_model_type = ufp_device.model + device_model_type = device.model assert device_model_type is not None # Runtime adoption must honor the same model-type allowlist as initial setup, # so unsupported devices (e.g. AI Port) get no entities when adopted live. @@ -215,7 +297,13 @@ def async_all_device_entities( return [] descs = _combine_model_descs(device_model_type, model_descriptions, all_descs) return _async_device_entities( - data, klass, device_model_type, descs, unadopted_descs, ufp_device + data, + klass, + device_model_type, + descs, + unadopted_descs, + ufp_device, + public_device, ) @@ -237,17 +325,24 @@ class BaseProtectEntity(Entity): # Values derived from the public events websocket (detection booleans, # public event entities) additionally require that websocket to be healthy. _ufp_requires_events_ws: bool = False + # False when the entity was built from a public object alone (public-only + # mode); ``device`` then holds that object and private fields are absent. + _ufp_has_private: bool = True def __init__( self, data: ProtectData, - device: ProtectDeviceType, + device: ProtectDeviceType | PublicDeviceModel, description: EntityDescription | None = None, ) -> None: """Initialize the entity.""" super().__init__() self.data = data - self.device = device + if isinstance(device, PublicDeviceModel): + self._ufp_has_private = False + self._ufp_public_obj = device + # The base keys on the mac, which both model trees carry. + self.device = cast(ProtectDeviceType, device) if description is None: self._attr_unique_id = self.device.mac @@ -321,6 +416,23 @@ class BaseProtectEntity(Entity): if available != was_available: self._attr_available = available + @callback + def _ufp_set_target(self) -> ProtectDeviceType | PublicDeviceModel: + """Return the object a description's setter is called on. + + A migrated description writes through the public object it reads from, + in both connection modes; the private device serves the rest. + """ + if not self._ufp_uses_public: + return self.device + if (public := self._ufp_public_obj) is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_not_available", + translation_placeholders={"device_name": self.device.display_name}, + ) + return public + @callback def _async_updated_event(self, device: ProtectDeviceType) -> None: """When device is updated from Protect.""" @@ -368,10 +480,7 @@ class BaseProtectEntity(Entity): # Not every entity carries an entity_description (e.g. cameras), so getattr. description = getattr(self, "entity_description", None) if isinstance(description, ProtectEntityDescription): - if ( - description.ufp_public_value is not None - or description.ufp_public_value_fn is not None - ): + if description.is_public_value: self._ufp_uses_public = True if description.ufp_event_driven: self._ufp_requires_events_ws = True @@ -412,6 +521,21 @@ class ProtectDeviceEntity(BaseProtectEntity): @callback @override def _async_set_device_info(self) -> None: + if not self._ufp_has_private: + # market_name/firmware/URL are private-only; the NVR link uses the + # device id registered at setup. + public = self._ufp_public_obj + if TYPE_CHECKING: + assert public is not None + self._attr_device_info = DeviceInfo( + name=public.display_name, + model=public.type, + model_id=public.type, + manufacturer=DEFAULT_BRAND, + connections={(dr.CONNECTION_NETWORK_MAC, public.mac)}, + via_device_id=self.data.nvr_device_id, + ) + return self._attr_device_info = DeviceInfo( name=self.device.display_name, manufacturer=DEFAULT_BRAND, @@ -597,17 +721,26 @@ class ProtectEntityDescription(EntityDescription, Generic[T]): # noqa: UP046 # Public counterpart of ``ufp_enabled``; a callable because public enablement # is often compound (e.g. mount type plus a settings flag). ufp_public_enabled_fn: Callable[[PublicDeviceModel], bool] | None = None - # Sensor capability required to create the entity, checked against the public - # capability map. Without a capability map (older firmware) every description - # is created, matching the pre-capability behavior. - ufp_capability: SensorFeatureCapability | None = None + # Capability required to create the entity: a sensor capability is checked + # against the public capability map (without one every description is + # created), a smart-detect type against the camera's advertised types. + ufp_capability: SensorFeatureCapability | SmartDetectObjectType | None = None ufp_perm: PermRequired | None = None # The below are set in __post_init__ has_required: Callable[[T], bool] = bool + # ``ufp_required_field`` against the public object; an attribute path the + # public model lacks reads as False, so private-only descriptions are + # skipped in public-only mode. + has_required_public: Callable[[PublicDeviceModel], bool] = bool get_ufp_enabled: Callable[[T], bool] | None = None get_ufp_public_value: Callable[[PublicDeviceModel], Any] | None = None + @property + def is_public_value(self) -> bool: + """Whether the value is read from the public object.""" + return self.ufp_public_value is not None or self.ufp_public_value_fn is not None + def get_ufp_value(self, obj: T) -> Any: """Return value from UniFi Protect device; overridden in __post_init__.""" # ufp_value or ufp_value_fn are required, the @@ -648,6 +781,10 @@ class ProtectEntityDescription(EntityDescription, Generic[T]): # noqa: UP046 if (ufp_required_field := self.ufp_required_field) is not None: _setter("has_required", make_required_getter(ufp_required_field)) + _setter( + "has_required_public", + partial(get_nested_attr_as_bool, tuple(ufp_required_field.split("."))), + ) @dataclass(frozen=True, kw_only=True) @@ -679,10 +816,12 @@ class ProtectEventMixin(ProtectEntityDescription[T]): class ProtectSettableKeysMixin(ProtectEntityDescription[T]): """Mixin for settable values.""" + # Called on the object the value is read from: the public object for a + # migrated description, the private device otherwise. ufp_set_method: str | None = None - ufp_set_method_fn: Callable[[T, Any], Coroutine[Any, Any, None]] | None = None + ufp_set_method_fn: Callable[[Any, Any], Coroutine[Any, Any, None]] | None = None - async def ufp_set(self, obj: T, value: Any) -> None: + async def ufp_set(self, obj: T | PublicDeviceModel, value: Any) -> None: """Set value for UniFi Protect device.""" _LOGGER.debug("Setting %s to %s for %s", self.key, value, obj.display_name) if self.ufp_set_method is not None: diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index f2266acc55be..f6dae4337142 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -14,12 +14,10 @@ from uiprotect.data.public_devices import PublicLight from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DEFAULT_BRAND, DOMAIN +from .const import DOMAIN from .data import ProtectData, ProtectDeviceType, UFPConfigEntry from .entity import ProtectDeviceEntity from .utils import async_ufp_instance_command @@ -107,30 +105,11 @@ class ProtectLight(ProtectDeviceEntity, LightEntity): private: Light | None, ) -> None: """Initialize the light.""" - self._private = private self._ufp_public_obj = public # unique_id and device info derive from the base device, so hybrid must # keep the private one to leave existing entities unchanged. super().__init__(data, cast(ProtectDeviceType, private or public)) - @callback - @override - def _async_set_device_info(self) -> None: - if self._private is not None: - super()._async_set_device_info() - return - # market_name/firmware/URL are private-only; the NVR link uses the - # device id registered at setup. - public = cast(PublicLight, self.device) - self._attr_device_info = DeviceInfo( - name=public.display_name, - model=public.type, - model_id=public.type, - manufacturer=DEFAULT_BRAND, - connections={(dr.CONNECTION_NETWORK_MAC, public.mac)}, - via_device_id=self.data.nvr_device_id, - ) - @callback @override def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 3499bfe0b090..3325a5b907e6 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -52,8 +52,8 @@ def _get_pir_duration_public(obj: PublicDeviceModel) -> int | None: return None if duration is None else round(duration / 1000) -async def _set_pir_duration(obj: Light, value: float) -> None: - await obj.set_duration_public(timedelta(seconds=value)) +async def _set_pir_duration(obj: PublicLight, value: float) -> None: + await obj.set_duration(timedelta(seconds=value)) def _get_chime_duration(obj: Camera) -> int: @@ -91,7 +91,7 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_step=1, ufp_required_field="has_mic", ufp_public_value="mic_volume", - ufp_set_method="set_mic_volume_public", + ufp_set_method="set_mic_volume", ufp_perm=PermRequired.WRITE, ), ProtectNumberEntityDescription( @@ -174,7 +174,7 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_max=100, ufp_step=1, ufp_public_value="light_device_settings.pir_sensitivity", - ufp_set_method="set_sensitivity_public", + ufp_set_method="set_sensitivity", ufp_perm=PermRequired.WRITE, ), ProtectNumberEntityDescription[Light]( @@ -201,7 +201,7 @@ SENSE_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_max=100, ufp_step=1, ufp_public_value="motion_settings.sensitivity", - ufp_set_method="set_motion_sensitivity_public", + ufp_set_method="set_motion_sensitivity", ufp_capability=SensorFeatureCapability.MOTION, ), ) @@ -337,7 +337,7 @@ class ProtectNumbers(ProtectDeviceEntity, NumberEntity): @override async def async_set_native_value(self, value: float) -> None: """Set new value.""" - await self.entity_description.ufp_set(self.device, value) + await self.entity_description.ufp_set(self._ufp_set_target(), value) class ChimeRingVolumeNumber(ProtectDeviceEntity, NumberEntity): diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 363f4d3c6add..d4a4a0ee5e26 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -28,6 +28,7 @@ from uiprotect.data import ( from uiprotect.data.public_devices import ( PublicCamera, PublicDeviceModel, + PublicLight, SensorFeatureCapability, ) from uiprotect.exceptions import GlobalAlarmManagerError @@ -173,9 +174,9 @@ def _get_doorbell_current(obj: Camera) -> str | None: return obj.lcd_message.text -async def _set_light_mode(obj: Light, mode: str) -> None: +async def _set_light_mode(obj: PublicLight, mode: str) -> None: lightmode, timing = LIGHT_MODE_TO_SETTINGS[mode] - await obj.set_light_mode_public( + await obj.set_light_mode( LightModeType(lightmode), enable_at=None if timing is None else LightModeEnableType(timing), ) @@ -234,9 +235,9 @@ def _get_hdr_mode_public(obj: PublicDeviceModel) -> str | None: return _HDR_MODE_MAP_INVERSE.get(cast(PublicCamera, obj).hdr_type) -async def _set_hdr_mode(obj: Camera, mode: str) -> None: +async def _set_hdr_mode(obj: PublicCamera, mode: str) -> None: """Set HDR mode via the public API.""" - await obj.set_hdr_mode_public(_HDR_MODE_MAP[mode]) + await obj.set_hdr_mode(_HDR_MODE_MAP[mode]) PTZ_PATROL_DESCRIPTION = ProtectSelectEntityDescription[Camera]( @@ -475,13 +476,15 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): # Light Motion is a bit different if self.entity_description.key == _KEY_LIGHT_MOTION: assert self.entity_description.ufp_set_method_fn is not None - await self.entity_description.ufp_set_method_fn(self.device, option) + await self.entity_description.ufp_set_method_fn( + self._ufp_set_target(), option + ) return unifi_value = self._hass_to_unifi_options[option] if self.entity_description.ufp_enum_type is not None: unifi_value = self.entity_description.ufp_enum_type(unifi_value) - await self.entity_description.ufp_set(self.device, unifi_value) + await self.entity_description.ufp_set(self._ufp_set_target(), unifi_value) class ProtectPTZPatrolSelect(ProtectDeviceEntity, SelectEntity): diff --git a/homeassistant/components/unifiprotect/siren.py b/homeassistant/components/unifiprotect/siren.py index 4256c022f32c..5b6cabfa928b 100644 --- a/homeassistant/components/unifiprotect/siren.py +++ b/homeassistant/components/unifiprotect/siren.py @@ -16,6 +16,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.util import dt as dt_util @@ -40,6 +41,17 @@ async def async_setup_entry( """Set up UniFi Protect siren entities from a config entry.""" data: ProtectData = entry.runtime_data + @callback + def _add_new_public_device(device: PublicDeviceModel) -> None: + # A siren has no private counterpart, so the adopt path never offers + # one; it arrives through the public add signal in both modes. + if isinstance(device, Siren): + async_add_entities([ProtectSiren(data, device)]) + + entry.async_on_unload( + async_dispatcher_connect(hass, data.public_add_signal, _add_new_public_device) + ) + api = data.api if not api.has_public_bootstrap: return diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index debed2d482a7..06f2878e00d7 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -841,6 +841,9 @@ "command_error": { "message": "Error communicating with UniFi Protect while sending command: {error}" }, + "device_not_available": { + "message": "{device_name} is no longer available" + }, "entry_auth_failed": { "message": "Authentication failed, please reauthenticate" }, diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index 3aeedcb05608..f35470a5d873 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -15,9 +15,10 @@ from uiprotect.data import ( RecordingMode, Relay, RelayOutputState, + SmartDetectObjectType, VideoMode, ) -from uiprotect.data.public_devices import SensorFeatureCapability +from uiprotect.data.public_devices import PublicCamera, SensorFeatureCapability from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory, Platform @@ -25,6 +26,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -56,8 +58,8 @@ class ProtectSwitchEntityDescription( """Describes UniFi Protect Switch entity.""" -async def _set_highfps(obj: Camera, value: bool) -> None: - await obj.set_video_mode_public(VideoMode.HIGH_FPS if value else VideoMode.DEFAULT) +async def _set_highfps(obj: PublicCamera, value: bool) -> None: + await obj.set_video_mode(VideoMode.HIGH_FPS if value else VideoMode.DEFAULT) async def _set_hdr(obj: Camera, value: bool) -> None: @@ -80,7 +82,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_led_status", ufp_public_value="led_settings.is_enabled", - ufp_set_method="set_status_light_public", + ufp_set_method="set_status_light", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription[Camera]( @@ -97,6 +99,9 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( key="high_fps", translation_key="high_fps", entity_category=EntityCategory.CONFIG, + # has_highfps has no public counterpart yet (uilibs/uiprotect#1201), so + # this stays unreachable in API-key-only mode even though the value + # and setter are migrated. ufp_required_field="feature_flags.has_highfps", ufp_public_value="is_high_fps_enabled", ufp_set_method_fn=_set_highfps, @@ -117,7 +122,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( translation_key="overlay_show_name", entity_category=EntityCategory.CONFIG, ufp_public_value="osd_settings.is_name_enabled", - ufp_set_method="set_osd_name_public", + ufp_set_method="set_osd_name", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -125,7 +130,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( translation_key="overlay_show_date", entity_category=EntityCategory.CONFIG, ufp_public_value="osd_settings.is_date_enabled", - ufp_set_method="set_osd_date_public", + ufp_set_method="set_osd_date", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -133,7 +138,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( translation_key="overlay_show_logo", entity_category=EntityCategory.CONFIG, ufp_public_value="osd_settings.is_logo_enabled", - ufp_set_method="set_osd_logo_public", + ufp_set_method="set_osd_logo", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -141,7 +146,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( translation_key="overlay_show_nerd_mode", entity_category=EntityCategory.CONFIG, ufp_public_value="osd_settings.is_debug_enabled", - ufp_set_method="set_osd_nerd_mode_public", + ufp_set_method="set_osd_nerd_mode", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -166,127 +171,127 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( key="smart_person", translation_key="detections_person", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_person", + ufp_capability=SmartDetectObjectType.PERSON, ufp_public_value="is_person_detection_on", - ufp_set_method="set_person_detection_public", + ufp_set_method="set_person_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_vehicle", translation_key="detections_vehicle", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_vehicle", + ufp_capability=SmartDetectObjectType.VEHICLE, ufp_public_value="is_vehicle_detection_on", - ufp_set_method="set_vehicle_detection_public", + ufp_set_method="set_vehicle_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_animal", translation_key="detections_animal", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_animal", + ufp_capability=SmartDetectObjectType.ANIMAL, ufp_public_value="is_animal_detection_on", - ufp_set_method="set_animal_detection_public", + ufp_set_method="set_animal_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_package", translation_key="detections_package", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_package", + ufp_capability=SmartDetectObjectType.PACKAGE, ufp_public_value="is_package_detection_on", - ufp_set_method="set_package_detection_public", + ufp_set_method="set_package_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_licenseplate", translation_key="detections_license_plate", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_license_plate", + ufp_capability=SmartDetectObjectType.LICENSE_PLATE, ufp_public_value="is_license_plate_detection_on", - ufp_set_method="set_license_plate_detection_public", + ufp_set_method="set_license_plate_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_smoke", translation_key="detections_smoke", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_smoke", + ufp_capability=SmartDetectObjectType.SMOKE, ufp_public_value="is_smoke_detection_on", - ufp_set_method="set_smoke_detection_public", + ufp_set_method="set_smoke_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_cmonx", translation_key="detections_co_alarm", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_co", + ufp_capability=SmartDetectObjectType.CMONX, ufp_public_value="is_co_detection_on", - ufp_set_method="set_co_detection_public", + ufp_set_method="set_co_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_siren", translation_key="detections_siren", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_siren", + ufp_capability=SmartDetectObjectType.SIREN, ufp_public_value="is_siren_detection_on", - ufp_set_method="set_siren_detection_public", + ufp_set_method="set_siren_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_baby_cry", translation_key="detections_baby_cry", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_baby_cry", + ufp_capability=SmartDetectObjectType.BABY_CRY, ufp_public_value="is_baby_cry_detection_on", - ufp_set_method="set_baby_cry_detection_public", + ufp_set_method="set_baby_cry_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_speak", translation_key="detections_speak", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_speaking", + ufp_capability=SmartDetectObjectType.SPEAK, ufp_public_value="is_speaking_detection_on", - ufp_set_method="set_speaking_detection_public", + ufp_set_method="set_speaking_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_bark", translation_key="detections_bark", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_bark", + ufp_capability=SmartDetectObjectType.BARK, ufp_public_value="is_bark_detection_on", - ufp_set_method="set_bark_detection_public", + ufp_set_method="set_bark_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_car_alarm", translation_key="detections_car_alarm", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_car_alarm", + ufp_capability=SmartDetectObjectType.BURGLAR, # Public API renamed "car alarm" to "burglar"; internal model keeps the legacy name. ufp_public_value="is_car_alarm_detection_on", - ufp_set_method="set_burglar_detection_public", + ufp_set_method="set_burglar_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_car_horn", translation_key="detections_car_horn", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_car_horn", + ufp_capability=SmartDetectObjectType.CAR_HORN, ufp_public_value="is_car_horn_detection_on", - ufp_set_method="set_car_horn_detection_public", + ufp_set_method="set_car_horn_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_glass_break", translation_key="detections_glass_break", entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_glass_break", + ufp_capability=SmartDetectObjectType.GLASS_BREAK, ufp_public_value="is_glass_break_detection_on", - ufp_set_method="set_glass_break_detection_public", + ufp_set_method="set_glass_break_detection", ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( @@ -325,7 +330,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( translation_key="detections_motion", entity_category=EntityCategory.CONFIG, ufp_public_value="motion_settings.is_enabled", - ufp_set_method="set_motion_status_public", + ufp_set_method="set_motion_status", ufp_capability=SensorFeatureCapability.MOTION, ), ProtectSwitchEntityDescription( @@ -333,7 +338,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( translation_key="temperature_sensor", entity_category=EntityCategory.CONFIG, ufp_public_value="temperature_settings.is_enabled", - ufp_set_method="set_temperature_status_public", + ufp_set_method="set_temperature_status", ufp_capability=SensorFeatureCapability.TEMPERATURE, ), ProtectSwitchEntityDescription( @@ -341,7 +346,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( translation_key="humidity_sensor", entity_category=EntityCategory.CONFIG, ufp_public_value="humidity_settings.is_enabled", - ufp_set_method="set_humidity_status_public", + ufp_set_method="set_humidity_status", ufp_capability=SensorFeatureCapability.HUMIDITY, ), ProtectSwitchEntityDescription( @@ -349,7 +354,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( translation_key="light_sensor", entity_category=EntityCategory.CONFIG, ufp_public_value="light_settings.is_enabled", - ufp_set_method="set_light_status_public", + ufp_set_method="set_light_status", ufp_capability=SensorFeatureCapability.LIGHT, ), ProtectSwitchEntityDescription( @@ -357,7 +362,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( translation_key="alarm_sound_detection", entity_category=EntityCategory.CONFIG, ufp_public_value="alarm_settings.is_enabled", - ufp_set_method="set_alarm_public", + ufp_set_method="set_alarm", ufp_capability=SensorFeatureCapability.SMOKE, ), ) @@ -378,7 +383,7 @@ LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( translation_key="status_light", entity_category=EntityCategory.CONFIG, ufp_public_value="light_device_settings.is_indicator_enabled", - ufp_set_method="set_status_light_public", + ufp_set_method="set_status_light", ufp_perm=PermRequired.WRITE, ), ) @@ -438,12 +443,12 @@ class ProtectBaseSwitch(ProtectIsOnEntity): @async_ufp_instance_command async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - await self.entity_description.ufp_set(self.device, True) + await self.entity_description.ufp_set(self._ufp_set_target(), True) @async_ufp_instance_command async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - await self.entity_description.ufp_set(self.device, False) + await self.entity_description.ufp_set(self._ufp_set_target(), False) class ProtectSwitch(ProtectDeviceEntity, ProtectBaseSwitch, SwitchEntity): @@ -550,33 +555,57 @@ async def async_setup_entry( entities += _make_entities(ProtectPrivacyModeSwitch, _PRIVACY_DESCRIPTIONS) async_add_entities(entities) + @callback + def _add_new_public_device(device: PublicDeviceModel) -> None: + if isinstance(device, Relay): + async_add_entities(_relay_output_switches(data, device)) + return + async_add_entities( + async_all_device_entities( + data, ProtectSwitch, _MODEL_DESCRIPTIONS, public_device=device + ) + ) + _make_entities = partial(async_all_device_entities, data) data.async_subscribe_adopt(_add_new_device) + entry.async_on_unload( + async_dispatcher_connect(hass, data.public_add_signal, _add_new_public_device) + ) entities: list[BaseProtectEntity] = [] entities += _make_entities(ProtectSwitch, _MODEL_DESCRIPTIONS) entities += _make_entities(ProtectPrivacyModeSwitch, _PRIVACY_DESCRIPTIONS) - bootstrap = data.api.bootstrap - nvr = bootstrap.nvr - if nvr.can_write(bootstrap.auth_user) and nvr.is_insights_enabled is not None: - entities.extend( - ProtectNVRSwitch(data, device=nvr, description=switch) - for switch in NVR_SWITCHES - ) + api = data.api + if not api.is_public_only: + # The NVR switches are private-only settings. + bootstrap = api.bootstrap + nvr = bootstrap.nvr + if nvr.can_write(bootstrap.auth_user) and nvr.is_insights_enabled is not None: + entities.extend( + ProtectNVRSwitch(data, device=nvr, description=switch) + for switch in NVR_SWITCHES + ) async_add_entities(entities) - # Public API: relay output switches. Only available when the public - # bootstrap has been primed (requires API key + supported NVR firmware). - api = data.api + # Relays exist only in the public API; a relay adopted later arrives + # through the public add signal in either mode. if api.has_public_bootstrap: - relay_entities: list[ProtectRelayOutputSwitch] = [ - ProtectRelayOutputSwitch(data, relay, output) + relay_entities = [ + entity for relay in api.public_bootstrap.relays.values() - for output in relay.outputs + for entity in _relay_output_switches(data, relay) ] if relay_entities: async_add_entities(relay_entities) +@callback +def _relay_output_switches( + data: ProtectData, relay: Relay +) -> list[ProtectRelayOutputSwitch]: + """Build one switch per output channel of a relay.""" + return [ProtectRelayOutputSwitch(data, relay, output) for output in relay.outputs] + + class ProtectRelayOutputSwitch(SwitchEntity): """Switch entity for a single relay output channel (Public API). diff --git a/homeassistant/components/unifiprotect/text.py b/homeassistant/components/unifiprotect/text.py index 2ffde3182f0c..94295231bf07 100644 --- a/homeassistant/components/unifiprotect/text.py +++ b/homeassistant/components/unifiprotect/text.py @@ -110,4 +110,4 @@ class ProtectDeviceText(ProtectDeviceEntity, TextEntity): @override async def async_set_value(self, value: str) -> None: """Change the value.""" - await self.entity_description.ufp_set(self.device, value) + await self.entity_description.ufp_set(self._ufp_set_target(), value) diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index a265e0d262d5..960c03d5a677 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -1,6 +1,6 @@ """Fixtures and test data for UniFi Protect methods.""" -from collections.abc import Callable, Coroutine, Generator, Iterator +from collections.abc import Callable, Coroutine, Generator from datetime import datetime, timedelta from functools import partial from ipaddress import IPv4Address @@ -24,8 +24,6 @@ from uiprotect.data import ( ModelType, NvrArmMode, NvrArmModeStatus, - ProtectModelWithId, - PublicBootstrap, Sensor, SmartDetectObjectType, StateType, @@ -56,7 +54,12 @@ from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util from . import _patch_discovery -from .utils import MockUFPFixture, make_public_camera, public_rtsps_for +from .utils import ( + MockUFPFixture, + make_public_bootstrap, + make_public_camera, + public_rtsps_for, +) from tests.common import MockConfigEntry, load_json_object_fixture @@ -191,17 +194,11 @@ def mock_ufp_client(bootstrap: Bootstrap): client.get_meta_info = AsyncMock(return_value=meta) # The library owns RTSPS streams on ``PublicCamera.rtsps_streams`` and primes - # them in ``update_public()``; the integration reads them synchronously. Start - # with empty collections; the ``update_public`` side effect (see ``mock_entry``) - # primes the cameras from the private bootstrap. - client.public_bootstrap = Mock(spec=PublicBootstrap) - client.public_bootstrap.cameras = {} - client.public_bootstrap.lights = {} - client.public_bootstrap.relays = {} - client.public_bootstrap.sirens = {} - client.public_bootstrap.fobs = {} - client.public_bootstrap.arm_profiles = {} - client.public_bootstrap.arm_mode = None + # them in ``update_public()``; the integration reads them synchronously. The + # ``update_public`` side effect (see ``mock_entry``) primes the cameras from + # the private bootstrap; other device families opt in via the + # ``setup_public_*`` helpers and default to no paired public object. + client.public_bootstrap = make_public_bootstrap() client.public_bootstrap.nvr = Mock() client.public_bootstrap.nvr.mac = nvr.mac client.public_bootstrap.nvr.name = nvr.name @@ -209,31 +206,6 @@ def mock_ufp_client(bootstrap: Bootstrap): client.public_bootstrap.nvr.device_type = None client.public_bootstrap.nvr.type = None - # Cameras and lights resolve to their primed public model (see - # ``update_public`` in ``mock_entry`` / ``setup_public_light``); other - # device types opt in via the ``setup_public_*`` helpers, so they default - # to no paired public object. - def _public_bootstrap_get( - model: ModelType, obj_id: str - ) -> ProtectModelWithId | None: - if model is ModelType.CAMERA: - return client.public_bootstrap.cameras.get(obj_id) - if model is ModelType.LIGHT: - return client.public_bootstrap.lights.get(obj_id) - return None - - client.public_bootstrap.get = Mock(side_effect=_public_bootstrap_get) - - def _public_all_devices() -> Iterator[Mock]: - pb = client.public_bootstrap - yield from pb.cameras.values() - yield from pb.lights.values() - yield from pb.relays.values() - yield from pb.sirens.values() - yield from pb.fobs.values() - - client.public_bootstrap.all_devices = _public_all_devices - async def get_camera_rtsps_streams( camera_id: str, *args: Any, **kwargs: Any ) -> RTSPSStreams | None: @@ -647,33 +619,11 @@ def mock_ufp_public_only_client() -> Mock: nvr.type = "UNVR4" nvr.id = "nvr-id" nvr.model = ModelType.NVR - pb = Mock(spec=PublicBootstrap) + # Tests replace whole device maps (``pb.lights = {...}``); the helper reads + # them at call time. + pb = make_public_bootstrap() pb.nvr = nvr pb.arm_mode = arm_mode - # One map per device family forwarded in public-only mode; tests replace - # them, so both helpers below read the attribute at call time. - pb.cameras = {} - pb.lights = {} - pb.fobs = {} - device_maps = { - ModelType.CAMERA: "cameras", - ModelType.LIGHT: "lights", - ModelType.FOB: "fobs", - } - - def _all_devices(*, include_nvr: bool = False) -> Iterator[Mock]: - if include_nvr and pb.nvr is not None: - yield pb.nvr - for attr in device_maps.values(): - yield from getattr(pb, attr).values() - - def _get(model: ModelType, obj_id: str) -> Mock | None: - if (attr := device_maps.get(model)) is None: - return None - return getattr(pb, attr).get(obj_id) - - pb.all_devices = _all_devices - pb.get = Mock(side_effect=_get) client.public_bootstrap = pb return client diff --git a/tests/components/unifiprotect/test_alarm_control_panel.py b/tests/components/unifiprotect/test_alarm_control_panel.py index 2c2dc41d8e5d..ac2f0db68817 100644 --- a/tests/components/unifiprotect/test_alarm_control_panel.py +++ b/tests/components/unifiprotect/test_alarm_control_panel.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import AsyncMock, Mock import pytest -from uiprotect.data import NVR, NvrArmMode, NvrArmModeStatus, PublicBootstrap +from uiprotect.data import NVR, NvrArmMode, NvrArmModeStatus from uiprotect.exceptions import GlobalAlarmManagerError from uiprotect.websocket import WebsocketState @@ -27,7 +27,12 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from .conftest import PUBLIC_ONLY_ALARM_ENTITY_ID -from .utils import MockUFPFixture, assert_entity_counts, init_entry +from .utils import ( + MockUFPFixture, + assert_entity_counts, + init_entry, + make_public_bootstrap, +) ALARM_ENTITY_ID = "alarm_control_panel.unifiprotect_alarm_manager" @@ -41,13 +46,7 @@ def _make_arm_mode(status: NvrArmModeStatus) -> Mock: def _make_public_bootstrap(arm_mode: Mock | None) -> Mock: """Create a PublicBootstrap with the given arm_mode.""" - pb = Mock(spec=PublicBootstrap) - pb.arm_mode = arm_mode - pb.arm_profiles = {} - pb.relays = {} - pb.sirens = {} - pb.fobs = {} - return pb + return make_public_bootstrap(arm_mode=arm_mode) async def test_alarm_panel_not_created_without_public_bootstrap( diff --git a/tests/components/unifiprotect/test_fob.py b/tests/components/unifiprotect/test_fob.py index b8cec638d26d..cf960cda957f 100644 --- a/tests/components/unifiprotect/test_fob.py +++ b/tests/components/unifiprotect/test_fob.py @@ -634,3 +634,56 @@ async def test_fob_added_at_runtime( ) == 5 ) + + +async def test_fob_added_after_setup_in_hybrid( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + caplog: pytest.LogCaptureFixture, +) -> None: + """A fob paired after setup gets its entities in hybrid mode too. + + The private bootstrap has no store for fobs, so the adopt path never sees + one; discovery goes through the public add signal in both modes. A + re-delivered frame must not add a second time. + """ + ufp.api.has_public_bootstrap = True + pb = _make_public_bootstrap(None) + ufp.api.public_bootstrap = pb + ufp.api.update_public = AsyncMock(return_value=pb) + + await init_entry(hass, ufp, []) + assert entity_registry.async_get(BATTERY_SENSOR) is None + + fob = _make_fob() + pb.fobs = {fob.id: fob} + msg = public_device_ws_message(fob) + msg.action = WSAction.ADD + assert ufp.devices_ws_subscription is not None + ufp.devices_ws_subscription(msg) + await hass.async_block_till_done() + + assert entity_registry.async_get(BATTERY_SENSOR) is not None + count = len( + [ + entry + for entry in entity_registry.entities.values() + if entry.unique_id.startswith(FOB_MAC) + ] + ) + + ufp.devices_ws_subscription(msg) + await hass.async_block_till_done() + + assert "already exists" not in caplog.text + assert ( + len( + [ + entry + for entry in entity_registry.entities.values() + if entry.unique_id.startswith(FOB_MAC) + ] + ) + == count + ) diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index a7e265a6ee13..6bf8c840bfe9 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -1,7 +1,7 @@ """Test the UniFi Protect number platform.""" from datetime import timedelta -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch import pytest from uiprotect.data import ( @@ -181,9 +181,11 @@ async def test_number_light_sensitivity( hass, Platform.NUMBER, light, description ) - with patch_ufp_method( - light, "set_sensitivity_public", new_callable=AsyncMock - ) as mock_method: + public = make_public_light(light) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object(public, "set_sensitivity", new_callable=AsyncMock) as mock_method: await hass.services.async_call( "number", "set_value", @@ -242,9 +244,11 @@ async def test_number_light_duration( hass, Platform.NUMBER, light, description ) - with patch_ufp_method( - light, "set_duration_public", new_callable=AsyncMock - ) as mock_method: + public = make_public_light(light) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object(public, "set_duration", new_callable=AsyncMock) as mock_method: await hass.services.async_call( "number", "set_value", @@ -328,14 +332,16 @@ async def test_number_light_duration_none( assert hass.states.get(entity_id).state == STATE_UNKNOWN -@pytest.mark.parametrize("description", CAMERA_NUMBERS) +@pytest.mark.parametrize( + "description", [d for d in CAMERA_NUMBERS if not d.is_public_value] +) async def test_number_camera_simple( hass: HomeAssistant, ufp: MockUFPFixture, camera_all_features: Camera, description: ProtectNumberEntityDescription, ) -> None: - """Tests simple numbers for cameras using the all features fixture.""" + """Tests the private-API numbers for cameras using the all features fixture.""" setup_public_camera(ufp) await init_entry(hass, ufp, [camera_all_features]) assert_entity_counts(hass, Platform.NUMBER, 7, 7) @@ -359,6 +365,33 @@ async def test_number_camera_simple( mock_method.assert_called_once_with(1.0) +async def test_number_camera_mic_volume_set( + hass: HomeAssistant, ufp: MockUFPFixture, camera_all_features: Camera +) -> None: + """The migrated mic volume number writes through the public object.""" + setup_public_camera(ufp) + await init_entry(hass, ufp, [camera_all_features]) + + description = next(d for d in CAMERA_NUMBERS if d.key == "mic_level") + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, camera_all_features, description + ) + + public = make_public_camera(camera_all_features) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object(public, "set_mic_volume", new_callable=AsyncMock) as mock_method: + await hass.services.async_call( + "number", + "set_value", + {ATTR_ENTITY_ID: entity_id, "value": 1.0}, + blocking=True, + ) + + mock_method.assert_called_once_with(1.0) + + async def test_number_camera_mic_volume_public_value( hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera ) -> None: @@ -481,8 +514,12 @@ async def test_number_sense_sensitivity_set( hass, Platform.NUMBER, sensor_all, SENSE_NUMBERS[0] ) - with patch_ufp_method( - sensor_all, "set_motion_sensitivity_public", new_callable=AsyncMock + public = make_public_sensor(sensor_all) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object( + public, "set_motion_sensitivity", new_callable=AsyncMock ) as mock_method: await hass.services.async_call( "number", diff --git a/tests/components/unifiprotect/test_relay.py b/tests/components/unifiprotect/test_relay.py index f84c892868ba..b4107c90e607 100644 --- a/tests/components/unifiprotect/test_relay.py +++ b/tests/components/unifiprotect/test_relay.py @@ -1,15 +1,16 @@ """Tests for the UniFi Protect relay (Public API) switch entities.""" -from collections.abc import Callable +from collections.abc import Callable, Coroutine +from typing import Any from unittest.mock import AsyncMock, Mock import pytest from uiprotect.data import ( ModelType, - PublicBootstrap, PublicRelayOutput, Relay, RelayOutputState, + WSAction, ) from uiprotect.exceptions import ClientError, NotAuthorized from uiprotect.websocket import WebsocketState @@ -29,7 +30,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from .utils import MockUFPFixture, init_entry +from .utils import ( + MockUFPFixture, + init_entry, + make_public_bootstrap, + public_device_ws_message, +) RELAY_ID = "relay-id-1" RELAY_MAC = "AA:BB:CC:DD:EE:01" @@ -75,13 +81,7 @@ def _make_relay( def _make_public_bootstrap(relay: Mock | None) -> Mock: """Build a public bootstrap mock holding the given relay.""" - pb = Mock(spec=PublicBootstrap) - pb.relays = {relay.id: relay} if relay is not None else {} - pb.arm_mode = None - pb.arm_profiles = {} - pb.sirens = {} - pb.fobs = {} - return pb + return make_public_bootstrap(relays={relay.id: relay} if relay is not None else {}) @pytest.fixture(name="ufp_with_relay") @@ -602,3 +602,113 @@ async def test_relay_switch_command_when_output_gone( {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, blocking=True, ) + + +async def test_relay_switch_public_only( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp_public_only: MockUFPFixture, + setup_public_only: Callable[[], Coroutine[Any, Any, None]], +) -> None: + """Relay output switches are public-API entities and work in API-key-only mode.""" + relay = _make_relay() + relay.outputs[0].state = RelayOutputState.ON + ufp_public_only.api.public_bootstrap.relays = {relay.id: relay} + + await setup_public_only() + + assert entity_registry.async_get(SWITCH_ENTITY_ID) is not None + assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, + blocking=True, + ) + relay.activate_output.assert_awaited_once_with(OUTPUT_ID, state="off") + + +@pytest.fixture(name="setup_hybrid") +def setup_hybrid_fixture( + hass: HomeAssistant, ufp: MockUFPFixture +) -> Callable[[], Coroutine[Any, Any, None]]: + """Return a callable setting up the hybrid entry with an empty public bootstrap.""" + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = _make_public_bootstrap(None) + + async def _setup() -> None: + await init_entry(hass, ufp, []) + + return _setup + + +def _add_relay_frame(ufp: MockUFPFixture, relay: Mock) -> None: + """Deliver a public devices websocket add frame for ``relay``.""" + ufp.api.public_bootstrap.relays[relay.id] = relay + msg = public_device_ws_message(relay) + msg.action = WSAction.ADD + ufp.devices_ws_subscription(msg) + + +@pytest.mark.parametrize( + ("ufp_fixture", "setup_fixture"), + [ + pytest.param("ufp_public_only", "setup_public_only", id="public_only"), + pytest.param("ufp", "setup_hybrid", id="hybrid"), + ], +) +async def test_relay_switch_added_after_setup( + hass: HomeAssistant, + request: pytest.FixtureRequest, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, + ufp_fixture: str, + setup_fixture: str, +) -> None: + """A relay adopted after setup gets its switches from its add frame in both modes. + + A relay has no private counterpart, so hybrid cannot discover it through + the adopt path either. A re-delivered frame must not add a second time. + """ + ufp: MockUFPFixture = request.getfixturevalue(ufp_fixture) + setup: Callable[[], Coroutine[Any, Any, None]] = request.getfixturevalue( + setup_fixture + ) + await setup() + assert entity_registry.async_get(SWITCH_ENTITY_ID) is None + + relay = _make_relay() + _add_relay_frame(ufp, relay) + await hass.async_block_till_done() + + assert entity_registry.async_get(SWITCH_ENTITY_ID) is not None + count = len(hass.states.async_entity_ids(SWITCH_DOMAIN)) + + _add_relay_frame(ufp, relay) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == count + assert "already exists" not in caplog.text + + +async def test_relay_switch_hybrid_startup_relay_not_added_twice( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + ufp_with_relay: tuple[MockUFPFixture, Mock], +) -> None: + """A relay enumerated at hybrid setup is in the add baseline. + + Its add frame (or the re-offer after a reconnect) must not create the + switches a second time. + """ + ufp, relay = ufp_with_relay + await init_entry(hass, ufp, []) + count = len(hass.states.async_entity_ids(SWITCH_DOMAIN)) + assert count + + _add_relay_frame(ufp, relay) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == count + assert "already exists" not in caplog.text diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index 535dd9b06e6f..b92133b5a527 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -2,7 +2,7 @@ from copy import copy from typing import Any -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch import pytest from uiprotect.data import ( @@ -20,7 +20,6 @@ from uiprotect.data import ( NvrArmMode, NvrArmModeStatus, PTZPatrol, - PublicBootstrap, PublicHdrMode, RecordingMode, Viewer, @@ -56,6 +55,7 @@ from .utils import ( assert_entity_counts, ids_from_device_description, init_entry, + make_public_bootstrap, make_public_camera, make_public_light, public_device_ws_message, @@ -429,9 +429,11 @@ async def test_select_set_option_light_motion( hass, Platform.SELECT, light, LIGHT_SELECTS[0] ) - with patch_ufp_method( - light, "set_light_mode_public", new_callable=AsyncMock - ) as mock_method: + public = make_public_light(light) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object(public, "set_light_mode", new_callable=AsyncMock) as mock_method: await hass.services.async_call( "select", "select_option", @@ -740,9 +742,11 @@ async def test_select_set_option_camera_hdr_mode( hass, Platform.SELECT, doorbell, description ) - with patch_ufp_method( - doorbell, "set_hdr_mode_public", new_callable=AsyncMock - ) as mock_method: + public = make_public_camera(doorbell) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object(public, "set_hdr_mode", new_callable=AsyncMock) as mock_method: await hass.services.async_call( "select", "select_option", @@ -1006,13 +1010,7 @@ def _make_nvr_arm_mode(profile_id: str | None = None) -> Mock: def _make_public_bootstrap(arm_mode: Mock | None, profiles: dict[str, Mock]) -> Mock: """Create a PublicBootstrap mock with arm profiles for testing.""" - pb = Mock(spec=PublicBootstrap) - pb.arm_mode = arm_mode - pb.arm_profiles = profiles - pb.relays = {} - pb.sirens = {} - pb.fobs = {} - return pb + return make_public_bootstrap(arm_mode=arm_mode, arm_profiles=profiles) async def test_select_nvr_arm_profile_not_created_without_public_bootstrap( diff --git a/tests/components/unifiprotect/test_siren.py b/tests/components/unifiprotect/test_siren.py index aca926ff5e49..dc16ef132eef 100644 --- a/tests/components/unifiprotect/test_siren.py +++ b/tests/components/unifiprotect/test_siren.py @@ -4,13 +4,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, Mock import pytest -from uiprotect.data import ( - ModelType, - PublicBootstrap, - PublicSirenStatus, - Siren, - SirenDuration, -) +from uiprotect.data import ModelType, PublicSirenStatus, Siren, SirenDuration, WSAction from uiprotect.exceptions import ClientError, NotAuthorized from uiprotect.websocket import WebsocketState @@ -34,7 +28,12 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt as dt_util -from .utils import MockUFPFixture, assert_entity_counts, init_entry +from .utils import ( + MockUFPFixture, + assert_entity_counts, + init_entry, + make_public_bootstrap, +) from tests.common import async_fire_time_changed @@ -68,13 +67,7 @@ def _make_siren(*, is_active: bool = False) -> Mock: def _make_public_bootstrap(siren: Mock | None) -> Mock: """Build a public bootstrap mock with the given siren.""" - pb = Mock(spec=PublicBootstrap) - pb.sirens = {siren.id: siren} if siren is not None else {} - pb.relays = {} - pb.arm_mode = None - pb.arm_profiles = {} - pb.fobs = {} - return pb + return make_public_bootstrap(sirens={siren.id: siren} if siren is not None else {}) def _make_ws_msg(siren: Mock, *, deleted: bool = False) -> Mock: @@ -673,3 +666,32 @@ async def test_siren_auto_off_timer_scheduled_at_startup( state = hass.states.get(SIREN_ENTITY_ID) assert state is not None assert state.state == STATE_OFF + + +async def test_siren_added_after_setup_in_hybrid( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + siren: Mock, +) -> None: + """A siren adopted after setup gets its entity in hybrid mode too. + + The private bootstrap has no store for sirens, so the adopt path never + sees one; discovery goes through the public add signal in both modes. + """ + ufp.api.has_public_bootstrap = True + pb = _make_public_bootstrap(None) + ufp.api.public_bootstrap = pb + ufp.api.update_public = AsyncMock(return_value=pb) + + await init_entry(hass, ufp, []) + assert entity_registry.async_get(SIREN_ENTITY_ID) is None + + pb.sirens = {siren.id: siren} + msg = _make_ws_msg(siren) + msg.action = WSAction.ADD + assert ufp.devices_ws_subscription is not None + ufp.devices_ws_subscription(msg) + await hass.async_block_till_done() + + assert entity_registry.async_get(SIREN_ENTITY_ID) is not None diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index f7f822016d3b..5cc9468e34ba 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -1,7 +1,9 @@ """Test the UniFi Protect switch platform.""" +from collections.abc import Callable, Coroutine +from functools import partial from typing import Any -from unittest.mock import AsyncMock, Mock, call +from unittest.mock import AsyncMock, Mock, call, patch import pytest from uiprotect.data import ( @@ -14,6 +16,7 @@ from uiprotect.data import ( SmartDetectAudioType, SmartDetectObjectType, VideoMode, + WSAction, ) from uiprotect.data.public_devices import SensorFeatureCapability from uiprotect.exceptions import ClientError, NotAuthorized @@ -28,6 +31,7 @@ from homeassistant.components.unifiprotect.switch import ( SENSE_SWITCHES, ProtectSwitchEntityDescription, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_ENTITY_ID, @@ -38,9 +42,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity_platform import async_get_platforms from . import patch_ufp_method +from .conftest import UNIFI_MAC from .utils import ( MockUFPFixture, adopt_devices, @@ -78,6 +84,8 @@ CAMERA_SWITCHES_NO_EXTRA = [ for d in CAMERA_SWITCHES_BASIC if d.key not in ("high_fps", "privacy_mode", "hdr_mode") ] +CAMERA_SWITCHES_PRIVATE = [d for d in CAMERA_SWITCHES_NO_EXTRA if not d.is_public_value] +CAMERA_SWITCHES_PUBLIC = [d for d in CAMERA_SWITCHES_NO_EXTRA if d.is_public_value] async def test_switch_camera_remove( @@ -300,8 +308,12 @@ async def test_switch_light_status( hass, Platform.SWITCH, light, description ) - with patch_ufp_method( - light, "set_status_light_public", new_callable=AsyncMock + public = make_public_light(light) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object( + public, "set_status_light", new_callable=AsyncMock ) as mock_method: await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -379,14 +391,14 @@ async def test_switch_camera_ssh( mock_method.assert_called_with(False) -@pytest.mark.parametrize("description", CAMERA_SWITCHES_NO_EXTRA) +@pytest.mark.parametrize("description", CAMERA_SWITCHES_PRIVATE) async def test_switch_camera_simple( hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, description: ProtectSwitchEntityDescription, ) -> None: - """Tests all simple switches for cameras.""" + """Tests the private-API camera switches.""" setup_public_camera(ufp) await init_entry(hass, ufp, [doorbell]) @@ -414,6 +426,44 @@ async def test_switch_camera_simple( mock_method.assert_called_with(False) +@pytest.mark.parametrize("description", CAMERA_SWITCHES_PUBLIC) +async def test_switch_camera_simple_public( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + description: ProtectSwitchEntityDescription, +) -> None: + """The migrated camera switches write through the public object.""" + + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell]) + + assert description.ufp_set_method is not None + + public = make_public_camera(doorbell) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object( + public, description.ufp_set_method, new_callable=AsyncMock + ) as mock_method: + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) + + await hass.services.async_call( + "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + mock_method.assert_called_once_with(True) + + await hass.services.async_call( + "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + mock_method.assert_called_with(False) + + async def test_switch_camera_highfps( hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ) -> None: @@ -429,9 +479,11 @@ async def test_switch_camera_highfps( hass, Platform.SWITCH, doorbell, description ) - with patch_ufp_method( - doorbell, "set_video_mode_public", new_callable=AsyncMock - ) as mock_method: + public = make_public_camera(doorbell) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object(public, "set_video_mode", new_callable=AsyncMock) as mock_method: await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) @@ -522,14 +574,18 @@ async def test_switch_camera_detections_public_api( await init_entry(hass, ufp, [doorbell]) assert description.ufp_set_method is not None - assert description.ufp_set_method.endswith("_public") + assert description.ufp_capability is not None _, entity_id = await ids_from_device_description( hass, Platform.SWITCH, doorbell, description ) - with patch_ufp_method( - doorbell, description.ufp_set_method, new_callable=AsyncMock + public = make_public_camera(doorbell) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object( + public, description.ufp_set_method, new_callable=AsyncMock ) as mock_method: await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -836,10 +892,14 @@ async def test_switch_turn_on_client_error( hass, Platform.SWITCH, light, description ) + public = make_public_light(light) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + with ( - patch_ufp_method( - light, - "set_status_light_public", + patch.object( + public, + "set_status_light", new_callable=AsyncMock, side_effect=ClientError("Test error"), ), @@ -864,10 +924,14 @@ async def test_switch_turn_on_not_authorized( hass, Platform.SWITCH, light, description ) + public = make_public_light(light) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + with ( - patch_ufp_method( - light, - "set_status_light_public", + patch.object( + public, + "set_status_light", new_callable=AsyncMock, side_effect=NotAuthorized("Not authorized"), ), @@ -975,11 +1039,11 @@ async def test_switch_sense_no_capability_map_keeps_existing( # The five sense settings the public API exposes, with the public-mock override # that flips them and the public setter each switch must write through. MIGRATED_SENSE_SWITCHES = [ - ("motion", "motion_enabled", "set_motion_status_public"), - ("temperature", "temperature_enabled", "set_temperature_status_public"), - ("humidity", "humidity_enabled", "set_humidity_status_public"), - ("light", "light_enabled", "set_light_status_public"), - ("alarm", "alarm_enabled", "set_alarm_public"), + ("motion", "motion_enabled", "set_motion_status"), + ("temperature", "temperature_enabled", "set_temperature_status"), + ("humidity", "humidity_enabled", "set_humidity_status"), + ("light", "light_enabled", "set_light_status"), + ("alarm", "alarm_enabled", "set_alarm"), ] @@ -1029,9 +1093,11 @@ async def test_switch_sense_set_public( hass, Platform.SWITCH, sensor_all, description ) - with patch_ufp_method( - sensor_all, set_method, new_callable=AsyncMock - ) as mock_method: + public = make_public_sensor(sensor_all) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + with patch.object(public, set_method, new_callable=AsyncMock) as mock_method: await hass.services.async_call( "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) @@ -1105,3 +1171,303 @@ async def test_switch_sense_public_switches_ignore_local_permissions( hass, Platform.SWITCH, sensor_all, description ) assert entity_registry.async_get(entity_id) is None + + +_SMART_KEYS = {key for key, _, _ in CAMERA_SWITCHES_DETECTION_READ} + + +def _switch_keys(entity_registry: er.EntityRegistry, mac: str) -> set[str]: + """Return the description keys of the switches registered for a device.""" + prefix = f"{mac}_" + return { + entry.unique_id.removeprefix(prefix) + for entry in entity_registry.entities.values() + if entry.domain == Platform.SWITCH and entry.unique_id.startswith(prefix) + } + + +def _make_streamless_public_camera(camera: Camera) -> Mock: + """Build a public camera without RTSPS streams (snapshot-only).""" + public = make_public_camera(camera) + public.rtsps_streams = None + return public + + +@pytest.mark.parametrize( + ("key", "object_types", "audio_types"), CAMERA_SWITCHES_DETECTION_READ +) +async def test_switch_camera_detection_capability_gating( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + doorbell: Camera, + key: str, + object_types: list[SmartDetectObjectType], + audio_types: list[SmartDetectAudioType], +) -> None: + """A detection switch exists only for a capability the camera advertises.""" + doorbell.feature_flags.smart_detect_types = object_types + doorbell.feature_flags.smart_detect_audio_types = audio_types + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell]) + + assert _switch_keys(entity_registry, doorbell.mac) & _SMART_KEYS == {key} + + +async def test_switch_command_when_public_object_vanishes( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + sensor_all: Sensor, + ufp_public_only: MockUFPFixture, + setup_public_only: Callable[[], Coroutine[Any, Any, None]], +) -> None: + """A switch deleted mid-call raises a translated error, not AttributeError. + + Service calls filter unavailable entities once up front and then run the + entity coroutines, so a delete frame landing after that check must not + reach the command path as a missing public object. + """ + public = make_public_sensor( + sensor_all, capabilities={SensorFeatureCapability.MOTION} + ) + pb = ufp_public_only.api.public_bootstrap + pb.sensors = {public.id: public} + + await setup_public_only() + entity_id = entity_registry.async_get_entity_id( + Platform.SWITCH, DOMAIN, f"{public.mac}_motion" + ) + assert entity_id + platform = next( + p for p in async_get_platforms(hass, DOMAIN) if p.domain == Platform.SWITCH + ) + entity = platform.entities[entity_id] + request_call = entity.async_request_call + + async def _delete_then_run(coro: Coroutine[Any, Any, Any]) -> Any: + """Drop the sensor after the availability filter, before the command.""" + pb.sensors.pop(public.id) + msg = public_device_ws_message(public) + msg.new_obj = None + msg.old_obj = public + ufp_public_only.devices_ws_subscription(msg) + return await request_call(coro) + + with ( + patch.object(entity, "async_request_call", _delete_then_run), + pytest.raises(HomeAssistantError) as err, + ): + await hass.services.async_call( + "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + public.set_motion_status.assert_not_called() + assert err.value.translation_domain == DOMAIN + assert err.value.translation_key == "device_not_available" + assert err.value.translation_placeholders == {"device_name": public.display_name} + + +async def test_switch_hybrid_public_sensor_without_private_deferred( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + sensor_all: Sensor, +) -> None: + """Hybrid leaves a public sensor without a private object to the adopt path. + + It gets no entities from the public object alone, and the capability + cleanup does not touch its registry entries either. + """ + setup_public_sensor(ufp, capabilities=_ENV_CAPABILITIES) + orphan = make_public_sensor(sensor_all, capabilities=_ENV_CAPABILITIES) + orphan.id = "orphan-sensor" + orphan.mac = "FFEEDDCCBB03" + ufp.api.public_bootstrap.sensors[orphan.id] = orphan + stale = entity_registry.async_get_or_create( + Platform.SWITCH, DOMAIN, f"{orphan.mac}_motion", config_entry=ufp.entry + ) + + await init_entry(hass, ufp, []) + + assert _switch_keys(entity_registry, orphan.mac) == {"motion"} + assert entity_registry.async_get(stale.entity_id) is not None + + +@pytest.mark.parametrize( + ("fixture_name", "make", "key", "setter", "absent_keys"), + [ + pytest.param( + "doorbell", + _make_streamless_public_camera, + "smart_person", + "set_person_detection", + {"ssh", "motion", "high_fps", "privacy_mode", "color_night_vision"}, + id="camera", + ), + pytest.param( + "sensor_all", + partial( + make_public_sensor, + motion_enabled=True, + capabilities={SensorFeatureCapability.MOTION}, + ), + "motion", + "set_motion_status", + {"status_light", "temperature"}, + id="sensor", + ), + pytest.param( + "light", + partial(make_public_light, is_indicator_enabled=True), + "status_light", + "set_status_light", + {"ssh"}, + id="light", + ), + ], +) +async def test_public_only_switch_end_to_end( + hass: HomeAssistant, + request: pytest.FixtureRequest, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + ufp_public_only: MockUFPFixture, + setup_public_only: Callable[[], Coroutine[Any, Any, None]], + fixture_name: str, + make: Callable[[Any], Mock], + key: str, + setter: str, + absent_keys: set[str], +) -> None: + """A public-only entry builds the migrated switches from the public object. + + Private-only switches and the NVR switches are absent, the device is + registered from public identity and commands go to the public setter. + """ + device = request.getfixturevalue(fixture_name) + public = make(device) + store = getattr(ufp_public_only.api.public_bootstrap, f"{device.model.value}s") + store[device.id] = public + + await setup_public_only() + + assert ufp_public_only.entry.state is ConfigEntryState.LOADED + keys = _switch_keys(entity_registry, device.mac) + assert key in keys + assert not keys & absent_keys + assert hass.states.get("switch.unifiprotect_insights_enabled") is None + + entity_id = entity_registry.async_get_entity_id( + Platform.SWITCH, DOMAIN, f"{device.mac}_{key}" + ) + assert entity_id + assert hass.states.get(entity_id).state == STATE_ON + + entry = entity_registry.async_get(entity_id) + assert entry + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.model == public.type + nvr_device = device_registry.async_get_device_by_identifier( + (DOMAIN, UNIFI_MAC), ufp_public_only.entry.entry_id + ) + assert nvr_device + assert device_entry.via_device_id == nvr_device.id + + await hass.services.async_call( + "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + getattr(public, setter).assert_awaited_once_with(False) + + +async def test_public_only_switch_camera_capability_gating( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + doorbell: Camera, + ufp_public_only: MockUFPFixture, + setup_public_only: Callable[[], Coroutine[Any, Any, None]], +) -> None: + """Without a private object the detection switches gate on the public capability.""" + doorbell.feature_flags.smart_detect_types = [SmartDetectObjectType.PERSON] + doorbell.feature_flags.smart_detect_audio_types = [] + public = _make_streamless_public_camera(doorbell) + ufp_public_only.api.public_bootstrap.cameras[doorbell.id] = public + + await setup_public_only() + + assert _switch_keys(entity_registry, doorbell.mac) & _SMART_KEYS == {"smart_person"} + + +@pytest.mark.parametrize( + ("fixture_name", "make", "key"), + [ + pytest.param( + "sensor_all", + partial(make_public_sensor, capabilities={SensorFeatureCapability.MOTION}), + "motion", + id="sensor", + ), + pytest.param( + "doorbell", _make_streamless_public_camera, "smart_person", id="camera" + ), + ], +) +async def test_public_only_switch_added_after_setup( + hass: HomeAssistant, + request: pytest.FixtureRequest, + entity_registry: er.EntityRegistry, + ufp_public_only: MockUFPFixture, + setup_public_only: Callable[[], Coroutine[Any, Any, None]], + caplog: pytest.LogCaptureFixture, + fixture_name: str, + make: Callable[[Any], Mock], + key: str, +) -> None: + """In public-only mode a device added later gets its switches from its add frame. + + The public devices websocket ``add`` frame is the only discovery signal + without a local user; a re-delivered frame must not add a second time. + """ + await setup_public_only() + assert_entity_counts(hass, Platform.SWITCH, 0, 0) + + device = request.getfixturevalue(fixture_name) + public = make(device) + store = getattr(ufp_public_only.api.public_bootstrap, f"{device.model.value}s") + store[device.id] = public + msg = public_device_ws_message(public) + msg.action = WSAction.ADD + ufp_public_only.devices_ws_subscription(msg) + await hass.async_block_till_done() + + assert key in _switch_keys(entity_registry, device.mac) + count = len(hass.states.async_entity_ids(Platform.SWITCH.value)) + + ufp_public_only.devices_ws_subscription(msg) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(Platform.SWITCH.value)) == count + assert "already exists" not in caplog.text + + +async def test_public_only_switch_sense_registry_cleanup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + sensor_all: Sensor, + ufp_public_only: MockUFPFixture, + setup_public_only: Callable[[], Coroutine[Any, Any, None]], +) -> None: + """The capability cleanup runs without a private bootstrap.""" + stale = entity_registry.async_get_or_create( + Platform.SWITCH, + DOMAIN, + f"{sensor_all.mac}_temperature", + config_entry=ufp_public_only.entry, + ) + ufp_public_only.api.public_bootstrap.sensors[sensor_all.id] = make_public_sensor( + sensor_all, capabilities={SensorFeatureCapability.MOTION} + ) + + await setup_public_only() + + assert entity_registry.async_get(stale.entity_id) is None diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 2d1f6dfc2d02..79c350e76e8e 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -1,9 +1,11 @@ """Test helpers for UniFi Protect.""" -from collections.abc import Callable, Sequence +from collections.abc import Callable, Iterator, Sequence from dataclasses import dataclass from datetime import timedelta -from unittest.mock import Mock +from functools import partial +from typing import Any +from unittest.mock import AsyncMock, Mock from uiprotect import EventChange, ProtectApiClient, ProtectEvent from uiprotect.api import RTSPSStreams @@ -31,6 +33,7 @@ from uiprotect.data import ( from uiprotect.data.bootstrap import ProtectDeviceRef from uiprotect.data.public_devices import ( PublicCamera, + PublicCameraFeatureFlags, PublicCameraLedSettings, PublicHdrMode, PublicLight, @@ -260,6 +263,77 @@ def public_rtsps_for(camera: Camera) -> RTSPSStreams: return RTSPSStreams(**urls) +_PUBLIC_STORE_ATTRS: dict[ModelType, str] = { + ModelType.CAMERA: "cameras", + ModelType.LIGHT: "lights", + ModelType.SENSOR: "sensors", + ModelType.CHIME: "chimes", + ModelType.VIEWPORT: "viewers", + ModelType.FOB: "fobs", +} + + +def make_public_bootstrap(**attrs: Any) -> Mock: + """Build a ``PublicBootstrap`` mock backed by per-family device maps. + + ``store_for``/``get``/``all_devices`` read the maps at call time, so a test + may replace a whole map after setup (``pb.lights = {...}``). + """ + pb = Mock(spec=PublicBootstrap) + for attr in (*_PUBLIC_STORE_ATTRS.values(), "relays", "sirens", "arm_profiles"): + setattr(pb, attr, {}) + pb.arm_mode = None + pb.nvr = None + for attr, value in attrs.items(): + setattr(pb, attr, value) + + def _store_for(model: ModelType) -> dict[str, Any] | None: + if (attr := _PUBLIC_STORE_ATTRS.get(model)) is None: + return None + return getattr(pb, attr) + + def _get(model: ModelType, obj_id: str) -> Any: + store = _store_for(model) + return None if store is None else store.get(obj_id) + + def _all_devices(*, include_nvr: bool = False) -> Iterator[Any]: + if include_nvr and pb.nvr is not None: + yield pb.nvr + for attr in (*_PUBLIC_STORE_ATTRS.values(), "relays", "sirens"): + yield from getattr(pb, attr).values() + + pb.store_for = Mock(side_effect=_store_for) + pb.get = Mock(side_effect=_get) + pb.all_devices = _all_devices + return pb + + +def _mirror_on_update_public( + ufp: MockUFPFixture, + private_store_attr: str, + store: dict[str, Any], + make: Callable[[Any], Mock], + *, + keep_existing: bool = False, +) -> None: + """Fill ``store`` from the private bootstrap whenever ``update_public`` runs. + + Mirrors the library prime so enumeration (which reads the store) sees the + public objects; lookups keep mirroring lazily for devices adopted later. + """ + previous = ufp.api.update_public + + async def _update_public(*args: Any, **kwargs: Any) -> Any: + result = await previous(*args, **kwargs) + for obj_id, private in getattr(ufp.api.bootstrap, private_store_attr).items(): + if keep_existing and obj_id in store: + continue + store[obj_id] = make(private) + return result + + ufp.api.update_public = AsyncMock(side_effect=_update_public) + + def make_public_sensor( sensor: Sensor, *, @@ -295,6 +369,9 @@ def make_public_sensor( public = Mock(spec=PublicSensor) public.id = sensor.id public.mac = sensor.mac + public.name = sensor.name + public.display_name = sensor.display_name + public.type = sensor.type public.model = ModelType.SENSOR public.state = DeviceState[sensor.state.name] if state is None else state public.mount_type = sensor.mount_type if mount_type is None else mount_type @@ -560,11 +637,22 @@ def make_public_camera( if hdr_type is None else hdr_type ) - public.has_package_camera = camera.feature_flags.has_package_camera - public.feature_flags = Mock() - public.feature_flags.support_full_hd_snapshot = ( - camera.feature_flags.support_full_hd_snapshot + flags = camera.feature_flags + public.has_package_camera = flags.has_package_camera + # Spec'd so a private-only flag (e.g. ``has_highfps``) reads as absent. + public.feature_flags = Mock(spec=PublicCameraFeatureFlags) + public.feature_flags.support_full_hd_snapshot = flags.support_full_hd_snapshot + public.feature_flags.has_hdr = flags.has_hdr + public.feature_flags.has_mic = flags.has_mic + public.feature_flags.has_led_status = flags.has_led_status + public.feature_flags.has_speaker = flags.has_speaker + public.feature_flags.video_modes = list(flags.video_modes) + public.feature_flags.smart_detect_types = list(flags.smart_detect_types) + public.feature_flags.smart_detect_audio_types = list( + flags.smart_detect_audio_types or [] ) + # The capability gate runs the library's own logic on the mirrored flags. + public.can_detect = Mock(side_effect=partial(PublicCamera.can_detect, public)) qualities = [ChannelQuality.HIGH, ChannelQuality.MEDIUM, ChannelQuality.LOW] if public.has_package_camera: qualities.append(ChannelQuality.PACKAGE) @@ -584,26 +672,19 @@ def setup_public_sensor( newer firmware with a capability map. """ public_bootstrap = PublicBootstrap() - pb = Mock(spec=PublicBootstrap) - pb.sensors = public_bootstrap.sensors - pb.relays = {} - pb.sirens = {} - pb.fobs = {} - pb.arm_mode = None - pb.arm_profiles = {} + pb = make_public_bootstrap(sensors=public_bootstrap.sensors) + make = partial(make_public_sensor, capabilities=capabilities) def _get(model: ModelType, obj_id: str) -> ProtectModelWithId | None: if ( model is ModelType.SENSOR and (private := ufp.api.bootstrap.sensors.get(obj_id)) is not None ): - public_bootstrap.sensors[obj_id] = make_public_sensor( - private, capabilities=capabilities - ) + public_bootstrap.sensors[obj_id] = make(private) return public_bootstrap.get(model, obj_id) pb.get = _get - pb.all_devices = public_bootstrap.all_devices + _mirror_on_update_public(ufp, "sensors", public_bootstrap.sensors, make) ufp.api.has_public_bootstrap = True ufp.api.public_bootstrap = pb @@ -615,13 +696,7 @@ def setup_public_light(ufp: MockUFPFixture) -> None: FloodLight duration number reads from the public object. """ public_bootstrap = PublicBootstrap() - pb = Mock(spec=PublicBootstrap) - pb.lights = public_bootstrap.lights - pb.relays = {} - pb.sirens = {} - pb.fobs = {} - pb.arm_mode = None - pb.arm_profiles = {} + pb = make_public_bootstrap(lights=public_bootstrap.lights) def _get(model: ModelType, obj_id: str) -> ProtectModelWithId | None: # One mock per id so command assertions hit the entity's cached object. @@ -634,7 +709,9 @@ def setup_public_light(ufp: MockUFPFixture) -> None: return public_bootstrap.get(model, obj_id) pb.get = _get - pb.all_devices = public_bootstrap.all_devices + _mirror_on_update_public( + ufp, "lights", public_bootstrap.lights, make_public_light, keep_existing=True + ) ufp.api.has_public_bootstrap = True ufp.api.public_bootstrap = pb @@ -646,13 +723,7 @@ def setup_public_camera(ufp: MockUFPFixture) -> None: camera config entities read from the public object. """ public_bootstrap = PublicBootstrap() - pb = Mock(spec=PublicBootstrap) - pb.cameras = public_bootstrap.cameras - pb.relays = {} - pb.sirens = {} - pb.fobs = {} - pb.arm_mode = None - pb.arm_profiles = {} + pb = make_public_bootstrap(cameras=public_bootstrap.cameras) def _get(model: ModelType, obj_id: str) -> ProtectModelWithId | None: if ( @@ -663,7 +734,6 @@ def setup_public_camera(ufp: MockUFPFixture) -> None: return public_bootstrap.get(model, obj_id) pb.get = _get - pb.all_devices = public_bootstrap.all_devices ufp.api.has_public_bootstrap = True ufp.api.public_bootstrap = pb