mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 09:23:17 -04:00
Enumerate UniFi Protect entities from the public API and add switches to the API-key-only mode (#181169)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user