Migrate the UniFi Protect sense sensors to the public API (#182662)

This commit is contained in:
Raphael Hehl
2026-09-19 14:00:27 +02:00
committed by GitHub
parent d4d558a93b
commit 006396782e
4 changed files with 117 additions and 20 deletions
@@ -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(
+4 -1
View File
@@ -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
+52 -1
View File
@@ -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
+51 -9
View File
@@ -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 (