mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 01:11:51 -04:00
Migrate the UniFi Protect sense sensors to the public API (#182662)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user