From fc229b336fc286bac11cfcc1cc0d75ec9666d917 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:44:17 +0200 Subject: [PATCH] Add UniFi Protect sensors to the API key only mode (#182748) --- .../components/unifiprotect/sensor.py | 70 ++++++--- tests/components/unifiprotect/test_fob.py | 37 ++--- tests/components/unifiprotect/test_sensor.py | 137 +++++++++++++++++- 3 files changed, 193 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 854640eddcab..d4481c4af1c3 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -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) diff --git a/tests/components/unifiprotect/test_fob.py b/tests/components/unifiprotect/test_fob.py index cf960cda957f..b54a5e23d0bc 100644 --- a/tests/components/unifiprotect/test_fob.py +++ b/tests/components/unifiprotect/test_fob.py @@ -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") diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index fc5595d5937a..3f187c902828 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -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