mirror of
https://github.com/home-assistant/core.git
synced 2026-10-06 22:38:02 -04:00
Add UniFi Protect sensors to the API key only mode (#182748)
This commit is contained in:
@@ -41,6 +41,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .data import ProtectData, ProtectDeviceType, UFPConfigEntry
|
||||
@@ -671,6 +672,25 @@ class ProtectFobSensor(ProtectFobEntity, SensorEntity):
|
||||
self._attr_native_value = self.entity_description.value_fn(fob)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_public_entities(
|
||||
data: ProtectData, device: PublicDeviceModel
|
||||
) -> list[Entity]:
|
||||
"""Return the sensors for one public device."""
|
||||
if isinstance(device, Fob):
|
||||
return [
|
||||
ProtectFobSensor(data, device, description) for description in FOB_SENSORS
|
||||
]
|
||||
return list(
|
||||
async_all_device_entities(
|
||||
data,
|
||||
ProtectDeviceSensor,
|
||||
model_descriptions=_MODEL_DESCRIPTIONS,
|
||||
public_device=device,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: UFPConfigEntry,
|
||||
@@ -681,36 +701,36 @@ async def async_setup_entry(
|
||||
|
||||
@callback
|
||||
def _add_new_public_device(device: PublicDeviceModel) -> None:
|
||||
if isinstance(device, Fob):
|
||||
async_add_entities(
|
||||
ProtectFobSensor(data, device, description)
|
||||
for description in FOB_SENSORS
|
||||
)
|
||||
async_add_entities(_async_public_entities(data, device))
|
||||
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(hass, data.public_add_signal, _add_new_public_device)
|
||||
)
|
||||
|
||||
async_remove_unsupported_sense_entities(hass, Platform.SENSOR, data, SENSE_SENSORS)
|
||||
|
||||
entities: list[Entity] = []
|
||||
# The public bootstrap is primed only with an API key and supported NVR
|
||||
# firmware; without it there are no fobs to expose.
|
||||
api = data.api
|
||||
if api.has_public_bootstrap:
|
||||
async_add_entities(
|
||||
ProtectFobSensor(data, fob, description)
|
||||
for fob in api.public_bootstrap.fobs.values()
|
||||
for description in FOB_SENSORS
|
||||
)
|
||||
for fob in api.public_bootstrap.fobs.values():
|
||||
entities.extend(_async_public_entities(data, fob))
|
||||
|
||||
# Everything below is driven by the private bootstrap, which public-only
|
||||
# entries do not have.
|
||||
if api.is_public_only:
|
||||
# The remaining sensors read the private bootstrap; the migrated ones
|
||||
# are built from the public devices instead.
|
||||
entities.extend(
|
||||
async_all_device_entities(
|
||||
data, ProtectDeviceSensor, model_descriptions=_MODEL_DESCRIPTIONS
|
||||
)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
return
|
||||
|
||||
async_remove_unsupported_sense_entities(hass, Platform.SENSOR, data, SENSE_SENSORS)
|
||||
|
||||
@callback
|
||||
def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
|
||||
entities = async_all_device_entities(
|
||||
device_entities = async_all_device_entities(
|
||||
data,
|
||||
ProtectDeviceSensor,
|
||||
all_descs=ALL_DEVICES_SENSORS,
|
||||
@@ -723,18 +743,20 @@ async def async_setup_entry(
|
||||
and isinstance(device, Camera)
|
||||
and device.is_adopted_by_us
|
||||
):
|
||||
entities += _async_event_entities(data, ufp_device=device)
|
||||
async_add_entities(entities)
|
||||
device_entities += _async_event_entities(data, ufp_device=device)
|
||||
async_add_entities(device_entities)
|
||||
|
||||
data.async_subscribe_adopt(_add_new_device)
|
||||
entities = async_all_device_entities(
|
||||
data,
|
||||
ProtectDeviceSensor,
|
||||
all_descs=ALL_DEVICES_SENSORS,
|
||||
model_descriptions=_MODEL_DESCRIPTIONS,
|
||||
entities.extend(
|
||||
async_all_device_entities(
|
||||
data,
|
||||
ProtectDeviceSensor,
|
||||
all_descs=ALL_DEVICES_SENSORS,
|
||||
model_descriptions=_MODEL_DESCRIPTIONS,
|
||||
)
|
||||
)
|
||||
entities += _async_event_entities(data)
|
||||
entities += _async_nvr_entities(data)
|
||||
entities.extend(_async_event_entities(data))
|
||||
entities.extend(_async_nvr_entities(data))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ from uiprotect.data import (
|
||||
FobAwayState,
|
||||
FobButton,
|
||||
ModelType,
|
||||
PublicBootstrap,
|
||||
PublicFobFeatureFlags,
|
||||
WSAction,
|
||||
)
|
||||
@@ -32,7 +31,13 @@ from homeassistant.core import Event as HAEvent, HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
|
||||
from .utils import MockUFPFixture, enable_entity, init_entry, public_device_ws_message
|
||||
from .utils import (
|
||||
MockUFPFixture,
|
||||
enable_entity,
|
||||
init_entry,
|
||||
make_public_bootstrap,
|
||||
public_device_ws_message,
|
||||
)
|
||||
|
||||
FOB_ID = "fob-id-1"
|
||||
FOB_MAC = "AA:BB:CC:DD:EE:F0"
|
||||
@@ -78,29 +83,11 @@ def _make_fob(
|
||||
|
||||
def _make_public_bootstrap(fob: Mock | None) -> Mock:
|
||||
"""Build a public bootstrap mock holding the given fob."""
|
||||
pb = Mock(spec=PublicBootstrap)
|
||||
pb.fobs = {fob.id: fob} if fob is not None else {}
|
||||
pb.cameras = {}
|
||||
pb.lights = {}
|
||||
pb.relays = {}
|
||||
pb.sirens = {}
|
||||
pb.arm_mode = None
|
||||
pb.arm_profiles = {}
|
||||
pb.nvr = Mock()
|
||||
pb.nvr.mac = "aa:bb:cc:dd:ee:ff"
|
||||
pb.nvr.name = "Test NVR"
|
||||
pb.nvr.display_name = "Test NVR"
|
||||
pb.nvr.device_type = None
|
||||
pb.nvr.type = None
|
||||
|
||||
# The baseline and reconnect resync enumerate all_devices(); a fob missing
|
||||
# from it would be redispatched as new on every reconnect.
|
||||
def _all_devices(*, include_nvr: bool = False) -> list[Mock]:
|
||||
devices = list(pb.fobs.values())
|
||||
return [pb.nvr, *devices] if include_nvr else devices
|
||||
|
||||
pb.all_devices = _all_devices
|
||||
return pb
|
||||
nvr = Mock(
|
||||
mac="aa:bb:cc:dd:ee:ff", display_name="Test NVR", device_type=None, type=None
|
||||
)
|
||||
nvr.name = "Test NVR"
|
||||
return make_public_bootstrap(fobs={fob.id: fob} if fob is not None else {}, nvr=nvr)
|
||||
|
||||
|
||||
@pytest.fixture(name="ufp_with_fob")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Test the UniFi Protect sensor platform."""
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
@@ -14,13 +16,14 @@ from uiprotect.data import (
|
||||
Light,
|
||||
ModelType,
|
||||
Sensor,
|
||||
WSAction,
|
||||
)
|
||||
from uiprotect.data.nvr import EventMetadata
|
||||
from uiprotect.data.public_devices import SensorFeatureCapability
|
||||
from uiprotect.utils import convert_to_datetime
|
||||
from uiprotect.utils import convert_to_datetime, to_js_time
|
||||
from uiprotect.websocket import WebsocketState
|
||||
|
||||
from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION
|
||||
from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION, DOMAIN
|
||||
from homeassistant.components.unifiprotect.sensor import (
|
||||
ALL_DEVICES_SENSORS,
|
||||
CAMERA_DISABLED_SENSORS,
|
||||
@@ -32,6 +35,7 @@ from homeassistant.components.unifiprotect.sensor import (
|
||||
SENSE_SENSORS,
|
||||
ProtectSensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
ATTR_ATTRIBUTION,
|
||||
STATE_UNAVAILABLE,
|
||||
@@ -40,6 +44,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .utils import (
|
||||
MockUFPFixture,
|
||||
@@ -48,6 +53,7 @@ from .utils import (
|
||||
enable_entity,
|
||||
ids_from_device_description,
|
||||
init_entry,
|
||||
make_public_camera,
|
||||
make_public_light,
|
||||
make_public_sensor,
|
||||
public_device_ws_message,
|
||||
@@ -824,3 +830,130 @@ async def test_sensor_light_last_motion_unavailable_without_public(
|
||||
await enable_entity(hass, ufp.entry.entry_id, entity_id)
|
||||
|
||||
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
def _sensor_keys(entity_registry: er.EntityRegistry, mac: str) -> set[str]:
|
||||
"""Return the description keys of the sensors registered for a device."""
|
||||
prefix = f"{mac}_"
|
||||
return {
|
||||
entry.unique_id.removeprefix(prefix)
|
||||
for entry in entity_registry.entities.values()
|
||||
if entry.domain == Platform.SENSOR and entry.unique_id.startswith(prefix)
|
||||
}
|
||||
|
||||
|
||||
async def test_public_only_sensor_sense_end_to_end(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
sensor_all: Sensor,
|
||||
ufp_public_only: MockUFPFixture,
|
||||
setup_public_only: Callable[[], Coroutine[Any, Any, None]],
|
||||
) -> None:
|
||||
"""A public-only entry builds the migrated sense sensors from the public object.
|
||||
|
||||
The readings and trip timestamps follow the capability map; the private-only
|
||||
sensors (alarm sound, sensitivity, mount type, paired camera) are absent.
|
||||
"""
|
||||
public = make_public_sensor(
|
||||
sensor_all,
|
||||
percentage=42,
|
||||
temperature_value=21.5,
|
||||
capabilities={
|
||||
SensorFeatureCapability.TEMPERATURE,
|
||||
SensorFeatureCapability.MOTION,
|
||||
},
|
||||
)
|
||||
ufp_public_only.api.public_bootstrap.sensors[sensor_all.id] = public
|
||||
|
||||
await setup_public_only()
|
||||
|
||||
assert ufp_public_only.entry.state is ConfigEntryState.LOADED
|
||||
keys = _sensor_keys(entity_registry, sensor_all.mac)
|
||||
assert {"battery_level", "temperature_level", "motion_last_trip_time"} <= keys
|
||||
assert not keys & {"alarm_sound", "sensitivity", "mount_type", "paired_camera"}
|
||||
assert "humidity_level" not in keys
|
||||
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
Platform.SENSOR, DOMAIN, f"{sensor_all.mac}_battery_level"
|
||||
)
|
||||
assert entity_id
|
||||
assert hass.states.get(entity_id).state == "42"
|
||||
|
||||
|
||||
async def test_public_only_sensor_light_end_to_end(
|
||||
entity_registry: er.EntityRegistry,
|
||||
light: Light,
|
||||
ufp_public_only: MockUFPFixture,
|
||||
setup_public_only: Callable[[], Coroutine[Any, Any, None]],
|
||||
) -> None:
|
||||
"""A public-only entry builds the migrated floodlight sensor.
|
||||
|
||||
``paired_camera`` reads the private bootstrap, so it stays absent. The trip
|
||||
timestamp is disabled by default, like its private counterpart.
|
||||
"""
|
||||
public = make_public_light(light, last_motion_ms=to_js_time(utcnow()))
|
||||
ufp_public_only.api.public_bootstrap.lights[light.id] = public
|
||||
|
||||
await setup_public_only()
|
||||
|
||||
keys = _sensor_keys(entity_registry, light.mac)
|
||||
assert "motion_last_trip_time" in keys
|
||||
assert "paired_camera" not in keys
|
||||
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
Platform.SENSOR, DOMAIN, f"{light.mac}_motion_last_trip_time"
|
||||
)
|
||||
assert entity_id
|
||||
assert (
|
||||
entity_registry.async_get(entity_id).disabled_by
|
||||
is er.RegistryEntryDisabler.INTEGRATION
|
||||
)
|
||||
|
||||
|
||||
async def test_public_only_sensor_camera_has_none(
|
||||
entity_registry: er.EntityRegistry,
|
||||
camera: Camera,
|
||||
ufp_public_only: MockUFPFixture,
|
||||
setup_public_only: Callable[[], Coroutine[Any, Any, None]],
|
||||
) -> None:
|
||||
"""The camera sensors all read the private bootstrap, so none are built."""
|
||||
public = make_public_camera(camera)
|
||||
public.rtsps_streams = None
|
||||
ufp_public_only.api.public_bootstrap.cameras[camera.id] = public
|
||||
|
||||
await setup_public_only()
|
||||
|
||||
assert _sensor_keys(entity_registry, camera.mac) == set()
|
||||
|
||||
|
||||
async def test_public_only_sensor_added_after_setup(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
light: Light,
|
||||
ufp_public_only: MockUFPFixture,
|
||||
setup_public_only: Callable[[], Coroutine[Any, Any, None]],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A light added later gets its sensors from its public 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.SENSOR, 0, 0)
|
||||
|
||||
public = make_public_light(light)
|
||||
ufp_public_only.api.public_bootstrap.lights[light.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 "motion_last_trip_time" in _sensor_keys(entity_registry, light.mac)
|
||||
count = len(hass.states.async_entity_ids(Platform.SENSOR.value))
|
||||
|
||||
ufp_public_only.devices_ws_subscription(msg)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(hass.states.async_entity_ids(Platform.SENSOR.value)) == count
|
||||
assert "already exists" not in caplog.text
|
||||
|
||||
Reference in New Issue
Block a user