From 006396782ef75d1b583bd3d4b581820388b200ac Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:00:27 +0200 Subject: [PATCH] Migrate the UniFi Protect sense sensors to the public API (#182662) --- .../components/unifiprotect/sensor.py | 19 +++--- tests/components/unifiprotect/conftest.py | 5 +- tests/components/unifiprotect/test_sensor.py | 53 +++++++++++++++- tests/components/unifiprotect/utils.py | 60 ++++++++++++++++--- 4 files changed, 117 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index c53b7125f71c..88f8943cc7af 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from datetime import datetime from functools import partial import logging +import operator from typing import Any, cast, override from uiprotect.data import ( @@ -325,8 +326,8 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, - ufp_value="stats.light.value", - ufp_enabled="is_light_sensor_enabled", + ufp_public_value="stats.light.value", + ufp_public_enabled_fn=operator.attrgetter("is_light_sensor_enabled"), ufp_capability=SensorFeatureCapability.LIGHT, ), ProtectSensorEntityDescription( @@ -334,8 +335,8 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, - ufp_value="stats.humidity.value", - ufp_enabled="is_humidity_sensor_enabled", + ufp_public_value="stats.humidity.value", + ufp_public_enabled_fn=operator.attrgetter("is_humidity_sensor_enabled"), ufp_capability=SensorFeatureCapability.HUMIDITY, ), ProtectSensorEntityDescription( @@ -343,8 +344,8 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - ufp_value="stats.temperature.value", - ufp_enabled="is_temperature_sensor_enabled", + ufp_public_value="stats.temperature.value", + ufp_public_enabled_fn=operator.attrgetter("is_temperature_sensor_enabled"), ufp_capability=SensorFeatureCapability.TEMPERATURE, ), ProtectSensorEntityDescription[Sensor]( @@ -358,7 +359,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( key="door_last_trip_time", translation_key="last_open", device_class=SensorDeviceClass.TIMESTAMP, - ufp_value="open_status_changed_at", + ufp_public_value="open_status_changed_at_dt", ufp_capability=SensorFeatureCapability.OPEN, entity_registry_enabled_default=False, ), @@ -366,7 +367,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( key="motion_last_trip_time", translation_key="last_motion_detected", device_class=SensorDeviceClass.TIMESTAMP, - ufp_value="motion_detected_at", + ufp_public_value="motion_detected_at_dt", ufp_capability=SensorFeatureCapability.MOTION, entity_registry_enabled_default=False, ), @@ -375,7 +376,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( translation_key="last_tampering_detected", ufp_capability=SensorFeatureCapability.TAMPER, device_class=SensorDeviceClass.TIMESTAMP, - ufp_value="tampering_detected_at", + ufp_public_value="tampering_detected_at_dt", entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index 960c03d5a677..58223014cb67 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -477,8 +477,11 @@ def sensor_fixture(fixed_now: datetime): data = load_json_object_fixture("sample_sensor.json", DOMAIN) sensor: Sensor = Sensor.from_unifi_dict(**data) + # Distinct offsets: these map to different public fields, and equal values + # would hide a swapped path. tampering stays unset on purpose, a value there + # would flip the tampering binary sensor. sensor.motion_detected_at = fixed_now - timedelta(hours=1) - sensor.open_status_changed_at = fixed_now - timedelta(hours=1) + sensor.open_status_changed_at = fixed_now - timedelta(hours=2) sensor.alarm_triggered_at = fixed_now - timedelta(hours=1) yield sensor diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index ab8e9c9fcc3f..fc5595d5937a 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -130,6 +130,28 @@ async def test_sensor_sense_capability_creation_filter( assert (entity_registry.async_get(entity_id) is not None) is created, key +async def test_sensor_sense_metrics_read_their_own_public_path( + hass: HomeAssistant, + ufp: MockUFPFixture, + sensor_all: Sensor, +) -> None: + """Each environmental sensor reads its own metric from the public object. + + The fixture reports the same number for light, humidity and temperature, so + a swapped ``ufp_public_value`` path would go unnoticed without diverging + values here. + """ + setup_public_sensor( + ufp, light_value=11.0, humidity_value=22.0, temperature_value=33.0 + ) + await init_entry(hass, ufp, [sensor_all]) + + name = sensor_all.name.lower().replace(" ", "_") + assert hass.states.get(f"sensor.{name}_illuminance").state == "11.0" + assert hass.states.get(f"sensor.{name}_humidity").state == "22.0" + assert hass.states.get(f"sensor.{name}_temperature").state == "33.0" + + async def test_sensor_setup_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -657,6 +679,7 @@ async def test_sensor_update_alarm_with_last_trip_time( ) -> None: """Test sensor motion entity with last trip time.""" + setup_public_sensor(ufp, tampering_detected_at=fixed_now - timedelta(hours=3)) await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.SENSOR, 22, 22) @@ -676,10 +699,38 @@ async def test_sensor_update_alarm_with_last_trip_time( assert state assert ( state.state - == (fixed_now - timedelta(hours=1)).replace(microsecond=0).isoformat() + == (fixed_now - timedelta(hours=2)).replace(microsecond=0).isoformat() ) assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + # Door and motion map to different public fields; asserting both with + # different offsets is what catches a swapped path. + _, motion_entity_id = await ids_from_device_description( + hass, + Platform.SENSOR, + sensor_all, + get_sensor_by_key(SENSE_SENSORS, "motion_last_trip_time"), + ) + motion_state = hass.states.get(motion_entity_id) + assert motion_state + assert ( + motion_state.state + == (fixed_now - timedelta(hours=1)).replace(microsecond=0).isoformat() + ) + + _, tamper_entity_id = await ids_from_device_description( + hass, + Platform.SENSOR, + sensor_all, + get_sensor_by_key(SENSE_SENSORS, "tampering_last_trip_time"), + ) + tamper_state = hass.states.get(tamper_entity_id) + assert tamper_state + assert ( + tamper_state.state + == (fixed_now - timedelta(hours=3)).replace(microsecond=0).isoformat() + ) + async def test_sensor_precision( hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor, fixed_now: datetime diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 79c350e76e8e..1983926a7fa6 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -2,7 +2,7 @@ from collections.abc import Callable, Iterator, Sequence from dataclasses import dataclass -from datetime import timedelta +from datetime import datetime, timedelta from functools import partial from typing import Any from unittest.mock import AsyncMock, Mock @@ -43,7 +43,9 @@ from uiprotect.data.public_devices import ( PublicSensor, PublicSensorAlarmSettingsRead, PublicSensorLeakSettings, + PublicSensorMetric, PublicSensorMotionSettingsRead, + PublicSensorStats, PublicSensorThresholdSettings, PublicSmartDetectSettings, PublicWirelessBatteryStatus, @@ -51,6 +53,7 @@ from uiprotect.data.public_devices import ( SensorFeatureCapability, ) from uiprotect.test_util.anonymize import random_hex +from uiprotect.utils import to_js_time from uiprotect.websocket import WebsocketState from homeassistant.const import Platform @@ -354,6 +357,10 @@ def make_public_sensor( capabilities: set[SensorFeatureCapability] | None = None, leak_internal_enabled: bool = False, leak_external_enabled: bool = False, + light_value: float | None = None, + humidity_value: float | None = None, + temperature_value: float | None = None, + tampering_detected_at: datetime | None = None, ) -> Mock: """Build a public-API sensor mirroring a private sensor's migrated fields. @@ -375,12 +382,6 @@ def make_public_sensor( 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 - public.is_contact_sensor_enabled = public.mount_type in { - MountType.DOOR, - MountType.WINDOW, - MountType.GARAGE, - } - public.is_leak_sensor_enabled = public.mount_type is MountType.LEAK public.is_opened = sensor.is_opened if is_opened is None else is_opened public.is_leak_detected = ( sensor.is_leak_detected if is_leak_detected is None else is_leak_detected @@ -447,6 +448,45 @@ def make_public_sensor( is_low=sensor.battery_status.is_low if is_low is None else is_low, ) ) + # The fixture reports the same number for all three metrics, so a test that + # has to tell the value paths apart passes its own. + public.stats = PublicSensorStats( + **{ + name: PublicSensorMetric( + value=getattr(sensor.stats, name).value if value is None else value + ) + for name, value in ( + ("light", light_value), + ("humidity", humidity_value), + ("temperature", temperature_value), + ) + } + ) + # The public API reports these as a JS epoch; the fixture leaves tampering + # unset, so a test asserting that path passes its own instant. + public.open_status_changed_at = to_js_time(sensor.open_status_changed_at) + public.motion_detected_at = to_js_time(sensor.motion_detected_at) + public.tampering_detected_at = to_js_time( + sensor.tampering_detected_at + if tampering_detected_at is None + else tampering_detected_at + ) + # Mocks do not evaluate properties, so derive them with the library's own + # logic: a wrong assumption about what gates a metric, or about how the + # epoch fields convert, fails the test. + for name in ( + "is_contact_sensor_enabled", + "is_leak_sensor_enabled", + "is_motion_sensor_enabled", + "is_alarm_sensor_enabled", + "is_temperature_sensor_enabled", + "is_humidity_sensor_enabled", + "is_light_sensor_enabled", + "open_status_changed_at_dt", + "motion_detected_at_dt", + "tampering_detected_at_dt", + ): + setattr(public, name, getattr(PublicSensor, name).fget(public)) return public @@ -663,17 +703,19 @@ def make_public_camera( def setup_public_sensor( ufp: MockUFPFixture, capabilities: set[SensorFeatureCapability] | None = None, + **mirror_overrides: Any, ) -> None: """Expose private sensors over the public API via a real ``PublicBootstrap``. Lookups go through the real ``PublicBootstrap.get``; the mirror resolves against the private bootstrap at call time, so it is robust to ``init_entry`` regenerating device ids. ``capabilities`` is forwarded to the mirror to model - newer firmware with a capability map. + newer firmware with a capability map. Further keyword arguments are handed + to ``make_public_sensor``, so a test can diverge a mirrored value. """ public_bootstrap = PublicBootstrap() pb = make_public_bootstrap(sensors=public_bootstrap.sensors) - make = partial(make_public_sensor, capabilities=capabilities) + make = partial(make_public_sensor, capabilities=capabilities, **mirror_overrides) def _get(model: ModelType, obj_id: str) -> ProtectModelWithId | None: if (