Enumerate UniFi Protect entities from the public API and add switches to the API-key-only mode (#181169)

This commit is contained in:
Raphael Hehl
2026-09-18 14:53:55 +00:00
committed by GitHub
parent 69c2215121
commit ae3e6c8ce2
21 changed files with 1174 additions and 383 deletions
@@ -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"),
@@ -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:
@@ -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
+63 -18
View File
@@ -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:
+175 -36
View File
@@ -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:
+1 -22
View File
@@ -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:
@@ -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):
@@ -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):
@@ -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
@@ -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"
},
+86 -57
View File
@@ -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).
@@ -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)
+15 -65
View File
@@ -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
@@ -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(
+53
View File
@@ -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
)
+48 -11
View File
@@ -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",
+120 -10
View File
@@ -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
+13 -15
View File
@@ -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(
+37 -15
View File
@@ -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
+392 -26
View File
@@ -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
+103 -33
View File
@@ -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