From 20768172b17d0e7f5b50985cdac2013db88b775b Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Wed, 12 Jan 2022 22:54:22 -0500 Subject: [PATCH] Improve UniFi Protect Smart Sensor support (#64019) Co-authored-by: J. Nick Koston --- .../components/unifiprotect/binary_sensor.py | 29 ++- homeassistant/components/unifiprotect/data.py | 17 +- .../components/unifiprotect/entity.py | 12 +- .../components/unifiprotect/manifest.json | 2 +- .../components/unifiprotect/models.py | 7 + .../components/unifiprotect/number.py | 16 ++ .../components/unifiprotect/select.py | 42 +++- .../components/unifiprotect/sensor.py | 27 ++- .../components/unifiprotect/switch.py | 51 +++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../unifiprotect/test_binary_sensor.py | 183 +++++++++++++++++- tests/components/unifiprotect/test_sensor.py | 133 ++++++++++++- 13 files changed, 501 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 1828819e771..6461d7d184d 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -5,7 +5,7 @@ from copy import copy from dataclasses import dataclass import logging -from pyunifiprotect.data import NVR, Camera, Event, Light, Sensor +from pyunifiprotect.data import NVR, Camera, Event, Light, MountType, Sensor from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -30,6 +30,7 @@ from .models import ProtectRequiredKeysMixin from .utils import get_nested_attr _LOGGER = logging.getLogger(__name__) +_KEY_DOOR = "door" @dataclass @@ -41,6 +42,13 @@ class ProtectBinaryEntityDescription( ufp_last_trip_value: str | None = None +MOUNT_DEVICE_CLASS_MAP = { + MountType.GARAGE: BinarySensorDeviceClass.GARAGE_DOOR, + MountType.WINDOW: BinarySensorDeviceClass.WINDOW, + MountType.DOOR: BinarySensorDeviceClass.DOOR, +} + + CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="doorbell", @@ -77,11 +85,12 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( - key="door", - name="Door", + key=_KEY_DOOR, + name="Contact", device_class=BinarySensorDeviceClass.DOOR, ufp_value="is_opened", ufp_last_trip_value="open_status_changed_at", + ufp_enabled="is_contact_sensor_enabled", ), ProtectBinaryEntityDescription( key="battery_low", @@ -96,6 +105,14 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.MOTION, ufp_value="is_motion_detected", ufp_last_trip_value="motion_detected_at", + ufp_enabled="is_motion_sensor_enabled", + ), + ProtectBinaryEntityDescription( + key="tampering", + name="Tampering Detected", + device_class=BinarySensorDeviceClass.TAMPER, + ufp_value="is_tampering_detected", + ufp_last_trip_value="tampering_detected_at", ), ) @@ -197,6 +214,12 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity): ATTR_LAST_TRIP_TIME: last_trip, } + # UP Sense can be any of the 3 contact sensor device classes + if self.entity_description.key == _KEY_DOOR and isinstance(self.device, Sensor): + self.entity_description.device_class = MOUNT_DEVICE_CLASS_MAP.get( + self.device.mount_type, BinarySensorDeviceClass.DOOR + ) + class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): """A UniFi Protect NVR Disk Binary Sensor.""" diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index cb58e21fb54..371c1c7831b 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -7,9 +7,14 @@ import logging from typing import Any from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient -from pyunifiprotect.data import Bootstrap, ModelType, WSSubscriptionMessage +from pyunifiprotect.data import ( + Bootstrap, + Event, + Liveview, + ModelType, + WSSubscriptionMessage, +) from pyunifiprotect.data.base import ProtectAdoptableDeviceModel, ProtectDeviceModel -from pyunifiprotect.data.nvr import Liveview from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback @@ -115,6 +120,14 @@ class ProtectData: for camera in self.api.bootstrap.cameras.values(): if camera.feature_flags.has_lcd_screen: self.async_signal_device_id_update(camera.id) + # trigger updates for camera that the event references + elif isinstance(message.new_obj, Event): + if message.new_obj.camera is not None: + self.async_signal_device_id_update(message.new_obj.camera.id) + elif message.new_obj.light is not None: + self.async_signal_device_id_update(message.new_obj.light.id) + elif message.new_obj.sensor is not None: + self.async_signal_device_id_update(message.new_obj.sensor.id) # alert user viewport needs restart so voice clients can get new options elif len(self.api.bootstrap.viewers) > 0 and isinstance( message.new_obj, Liveview diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 03d4458c0b0..7d3aeb6fff0 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -149,9 +149,19 @@ class ProtectDeviceEntity(Entity): devices = getattr(self.data.api.bootstrap, f"{self.device.model.value}s") self.device = devices[self.device.id] - self._attr_available = ( + is_connected = ( self.data.last_update_success and self.device.state == StateType.CONNECTED ) + if ( + hasattr(self, "entity_description") + and self.entity_description is not None + and hasattr(self.entity_description, "get_ufp_enabled") + ): + assert isinstance(self.entity_description, ProtectRequiredKeysMixin) + is_connected = is_connected and self.entity_description.get_ufp_enabled( + self.device + ) + self._attr_available = is_connected @callback def _async_updated_event(self) -> None: diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index d60697969bf..dfa643833dd 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifiprotect", "requirements": [ - "pyunifiprotect==1.6.2" + "pyunifiprotect==1.6.3" ], "dependencies": [ "http" diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py index 599e755a39e..49e6c61907f 100644 --- a/homeassistant/components/unifiprotect/models.py +++ b/homeassistant/components/unifiprotect/models.py @@ -22,6 +22,7 @@ class ProtectRequiredKeysMixin: ufp_required_field: str | None = None ufp_value: str | None = None ufp_value_fn: Callable[[ProtectAdoptableDeviceModel | NVR], Any] | None = None + ufp_enabled: str | None = None def get_ufp_value(self, obj: ProtectAdoptableDeviceModel | NVR) -> Any: """Return value from UniFi Protect device.""" @@ -35,6 +36,12 @@ class ProtectRequiredKeysMixin: "`ufp_value` or `ufp_value_fn` is required" ) + def get_ufp_enabled(self, obj: ProtectAdoptableDeviceModel | NVR) -> bool: + """Return value from UniFi Protect device.""" + if self.ufp_enabled is not None: + return bool(get_nested_attr(obj, self.ufp_enabled)) + return True + @dataclass class ProtectSetableKeysMixin(ProtectRequiredKeysMixin): diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 429844b52dd..9a5cd565090 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -111,6 +111,21 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ), ) +SENSE_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( + ProtectNumberEntityDescription( + key="sensitivity", + name="Motion Sensitivity", + icon="mdi:walk", + entity_category=EntityCategory.CONFIG, + ufp_min=0, + ufp_max=100, + ufp_step=1, + ufp_required_field=None, + ufp_value="motion_settings.sensitivity", + ufp_set_method="set_motion_sensitivity", + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -124,6 +139,7 @@ async def async_setup_entry( ProtectNumbers, camera_descs=CAMERA_NUMBERS, light_descs=LIGHT_NUMBERS, + sense_descs=SENSE_NUMBERS, ) async_add_entities(entities) diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 95e4b3152d8..b2995736eeb 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -19,7 +19,10 @@ from pyunifiprotect.data import ( RecordingMode, Viewer, ) -from pyunifiprotect.data.types import ChimeType +from pyunifiprotect.data.base import ProtectAdoptableDeviceModel +from pyunifiprotect.data.devices import Sensor +from pyunifiprotect.data.nvr import NVR +from pyunifiprotect.data.types import ChimeType, MountType import voluptuous as vol from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -52,6 +55,14 @@ CHIME_TYPES = [ {"id": ChimeType.DIGITAL.value, "name": "Digital"}, ] +MOUNT_TYPES = [ + {"id": MountType.NONE.value, "name": "None"}, + {"id": MountType.DOOR.value, "name": "Door"}, + {"id": MountType.WINDOW.value, "name": "Window"}, + {"id": MountType.GARAGE.value, "name": "Garage"}, + {"id": MountType.LEAK.value, "name": "Leak"}, +] + LIGHT_MODE_MOTION = "On Motion - Always" LIGHT_MODE_MOTION_DARK = "On Motion - When Dark" LIGHT_MODE_DARK = "When Dark" @@ -161,8 +172,10 @@ async def _set_light_mode(obj: Any, mode: str) -> None: ) -async def _set_paired_camera(obj: Any, camera_id: str) -> None: - assert isinstance(obj, Light) +async def _set_paired_camera( + obj: ProtectAdoptableDeviceModel | NVR, camera_id: str +) -> None: + assert isinstance(obj, (Sensor, Light)) if camera_id == TYPE_EMPTY_VALUE: camera: Camera | None = None else: @@ -253,6 +266,28 @@ LIGHT_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ), ) +SENSE_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( + ProtectSelectEntityDescription( + key="mount_type", + name="Mount Type", + icon="mdi:screwdriver", + entity_category=EntityCategory.CONFIG, + ufp_options=MOUNT_TYPES, + ufp_enum_type=MountType, + ufp_value="mount_type", + ufp_set_method="set_mount_type", + ), + ProtectSelectEntityDescription( + key="paired_camera", + name="Paired Camera", + icon="mdi:cctv", + entity_category=EntityCategory.CONFIG, + ufp_value="camera_id", + ufp_options_callable=_get_paired_camera_options, + ufp_set_method_fn=_set_paired_camera, + ), +) + VIEWER_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription( key="viewer", @@ -278,6 +313,7 @@ async def async_setup_entry( ProtectSelects, camera_descs=CAMERA_SELECTS, light_descs=LIGHT_SELECTS, + sense_descs=SENSE_SELECTS, viewer_descs=VIEWER_SELECTS, ) diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 82164174e62..b51e2c0475b 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -8,6 +8,7 @@ from typing import Any from pyunifiprotect.data import NVR, Camera, Event from pyunifiprotect.data.base import ProtectAdoptableDeviceModel +from pyunifiprotect.data.devices import Sensor from homeassistant.components.sensor import ( SensorDeviceClass, @@ -42,7 +43,7 @@ from .entity import ( from .models import ProtectRequiredKeysMixin _LOGGER = logging.getLogger(__name__) -DETECTED_OBJECT_NONE = "none" +OBJECT_TYPE_NONE = "none" DEVICE_CLASS_DETECTION = "unifiprotect__detection" @@ -88,6 +89,19 @@ def _get_nvr_memory(obj: Any) -> float | None: return (1 - memory.available / memory.total) * 100 +def _get_alarm_sound(obj: ProtectAdoptableDeviceModel | NVR) -> str: + assert isinstance(obj, Sensor) + + alarm_type = OBJECT_TYPE_NONE + if ( + obj.is_alarm_detected + and obj.last_alarm_event is not None + and obj.last_alarm_event.metadata is not None + ): + alarm_type = obj.last_alarm_event.metadata.alarm_type or OBJECT_TYPE_NONE + return alarm_type.lower() + + ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="uptime", @@ -210,6 +224,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, ufp_value="stats.light.value", + ufp_enabled="is_light_sensor_enabled", ), ProtectSensorEntityDescription( key="humidity_level", @@ -218,6 +233,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ufp_value="stats.humidity.value", + ufp_enabled="is_humidity_sensor_enabled", ), ProtectSensorEntityDescription( key="temperature_level", @@ -226,6 +242,13 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ufp_value="stats.temperature.value", + ufp_enabled="is_temperature_sensor_enabled", + ), + ProtectSensorEntityDescription( + key="alarm_sound", + name="Alarm Sound Detected", + ufp_value_fn=_get_alarm_sound, + ufp_enabled="is_alarm_sensor_enabled", ), ) @@ -479,6 +502,6 @@ class ProtectEventSensor(ProtectDeviceSensor, EventThumbnailMixin): # do not call ProtectDeviceSensor method since we want event to get value here EventThumbnailMixin._async_update_device_from_protect(self) if self._event is None: - self._attr_native_value = DETECTED_OBJECT_NONE + self._attr_native_value = OBJECT_TYPE_NONE else: self._attr_native_value = self._event.smart_detect_types[0].value diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index 2b534622533..d2a2267f488 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -152,6 +152,56 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ) +SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( + ProtectSwitchEntityDescription( + key="status_light", + name="Status Light On", + icon="mdi:led-on", + entity_category=EntityCategory.CONFIG, + ufp_value="led_settings.is_enabled", + ufp_set_method="set_status_light", + ), + ProtectSwitchEntityDescription( + key="motion", + name="Motion Detection", + icon="mdi:walk", + entity_category=EntityCategory.CONFIG, + ufp_value="motion_settings.is_enabled", + ufp_set_method="set_motion_status", + ), + ProtectSwitchEntityDescription( + key="temperature", + name="Temperature Sensor", + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + ufp_value="temperature_settings.is_enabled", + ufp_set_method="set_temperature_status", + ), + ProtectSwitchEntityDescription( + key="humidity", + name="Humidity Sensor", + icon="mdi:water-percent", + entity_category=EntityCategory.CONFIG, + ufp_value="humidity_settings.is_enabled", + ufp_set_method="set_humidity_status", + ), + ProtectSwitchEntityDescription( + key="light", + name="Light Sensor", + icon="mdi:brightness-5", + entity_category=EntityCategory.CONFIG, + ufp_value="light_settings.is_enabled", + ufp_set_method="set_light_status", + ), + ProtectSwitchEntityDescription( + key="alarm", + name="Alarm Sound Detection", + entity_category=EntityCategory.CONFIG, + ufp_value="alarm_settings.is_enabled", + ufp_set_method="set_alarm_status", + ), +) + LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( @@ -178,6 +228,7 @@ async def async_setup_entry( all_descs=ALL_DEVICES_SWITCHES, camera_descs=CAMERA_SWITCHES, light_descs=LIGHT_SWITCHES, + sense_descs=SENSE_SWITCHES, ) async_add_entities(entities) diff --git a/requirements_all.txt b/requirements_all.txt index 8f88972c09a..bfa230041d6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2015,7 +2015,7 @@ pytrafikverket==0.1.6.2 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==1.6.2 +pyunifiprotect==1.6.3 # homeassistant.components.uptimerobot pyuptimerobot==21.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f34a7143be..56d1e6f0a81 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1240,7 +1240,7 @@ pytrafikverket==0.1.6.2 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==1.6.2 +pyunifiprotect==1.6.3 # homeassistant.components.uptimerobot pyuptimerobot==21.11.0 diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 89d974f9f5c..88f19e59d7d 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -7,8 +7,10 @@ from datetime import datetime, timedelta from unittest.mock import Mock import pytest -from pyunifiprotect.data import Camera, Event, EventType, Light, Sensor +from pyunifiprotect.data import Camera, Event, EventType, Light, MountType, Sensor +from pyunifiprotect.data.nvr import EventMetadata +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.unifiprotect.binary_sensor import ( CAMERA_SENSORS, LIGHT_SENSORS, @@ -21,9 +23,11 @@ from homeassistant.components.unifiprotect.const import ( ) from homeassistant.const import ( ATTR_ATTRIBUTION, + ATTR_DEVICE_CLASS, ATTR_LAST_TRIP_TIME, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant @@ -157,11 +161,15 @@ async def sensor_fixture( sensor_obj = mock_sensor.copy(deep=True) sensor_obj._api = mock_entry.api sensor_obj.name = "Test Sensor" + sensor_obj.mount_type = MountType.DOOR sensor_obj.is_opened = False sensor_obj.battery_status.is_low = False sensor_obj.is_motion_detected = False + sensor_obj.alarm_settings.is_enabled = True sensor_obj.motion_detected_at = now - timedelta(hours=1) sensor_obj.open_status_changed_at = now - timedelta(hours=1) + sensor_obj.alarm_triggered_at = now - timedelta(hours=1) + sensor_obj.tampering_detected_at = now - timedelta(hours=1) mock_entry.api.bootstrap.reset_objects() mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] @@ -172,7 +180,43 @@ async def sensor_fixture( await hass.config_entries.async_setup(mock_entry.entry.entry_id) await hass.async_block_till_done() - assert_entity_counts(hass, Platform.BINARY_SENSOR, 3, 3) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 4, 4) + + yield sensor_obj + + Sensor.__config__.validate_assignment = True + + +@pytest.fixture(name="sensor_none") +async def sensor_none_fixture( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + mock_sensor: Sensor, + now: datetime, +): + """Fixture for a single sensor for testing the binary_sensor platform.""" + + # disable pydantic validation so mocking can happen + Sensor.__config__.validate_assignment = False + + sensor_obj = mock_sensor.copy(deep=True) + sensor_obj._api = mock_entry.api + sensor_obj.name = "Test Sensor" + sensor_obj.mount_type = MountType.LEAK + sensor_obj.battery_status.is_low = False + sensor_obj.alarm_settings.is_enabled = False + sensor_obj.tampering_detected_at = now - timedelta(hours=1) + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] + mock_entry.api.bootstrap.sensors = { + sensor_obj.id: sensor_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.BINARY_SENSOR, 4, 4) yield sensor_obj @@ -308,6 +352,35 @@ async def test_binary_sensor_setup_sensor( assert state.attributes[ATTR_LAST_TRIP_TIME] == expected_trip_time +async def test_binary_sensor_setup_sensor_none( + hass: HomeAssistant, sensor_none: Sensor +): + """Test binary_sensor entity setup for sensor with most sensors disabled.""" + + entity_registry = er.async_get(hass) + + expected = [ + STATE_UNAVAILABLE, + STATE_OFF, + STATE_UNAVAILABLE, + STATE_OFF, + ] + for index, description in enumerate(SENSE_SENSORS): + unique_id, entity_id = ids_from_device_description( + Platform.BINARY_SENSOR, sensor_none, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + print(entity_id) + assert state.state == expected[index] + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + async def test_binary_sensor_update_motion( hass: HomeAssistant, mock_entry: MockEntityFixture, camera: Camera, now: datetime ): @@ -348,3 +421,109 @@ async def test_binary_sensor_update_motion( assert state.state == STATE_ON assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION assert state.attributes[ATTR_EVENT_SCORE] == 100 + + +async def test_binary_sensor_update_light_motion( + hass: HomeAssistant, mock_entry: MockEntityFixture, light: Light, now: datetime +): + """Test binary_sensor motion entity.""" + + _, entity_id = ids_from_device_description( + Platform.BINARY_SENSOR, light, LIGHT_SENSORS[1] + ) + + event_metadata = EventMetadata(light_id=light.id) + event = Event( + id="test_event_id", + type=EventType.MOTION_LIGHT, + start=now - timedelta(seconds=1), + end=None, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + metadata=event_metadata, + api=mock_entry.api, + ) + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_light = light.copy() + new_light.is_pir_motion_detected = True + new_light.last_motion_event_id = event.id + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + + new_bootstrap.lights = {new_light.id: new_light} + new_bootstrap.events = {event.id: event} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + + +async def test_binary_sensor_update_mount_type_window( + hass: HomeAssistant, mock_entry: MockEntityFixture, sensor: Sensor +): + """Test binary_sensor motion entity.""" + + _, entity_id = ids_from_device_description( + Platform.BINARY_SENSOR, sensor, SENSE_SENSORS[0] + ) + + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR.value + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_sensor = sensor.copy() + new_sensor.mount_type = MountType.WINDOW + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_sensor + + new_bootstrap.sensors = {new_sensor.id: new_sensor} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.WINDOW.value + + +async def test_binary_sensor_update_mount_type_garage( + hass: HomeAssistant, mock_entry: MockEntityFixture, sensor: Sensor +): + """Test binary_sensor motion entity.""" + + _, entity_id = ids_from_device_description( + Platform.BINARY_SENSOR, sensor, SENSE_SENSORS[0] + ) + + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR.value + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_sensor = sensor.copy() + new_sensor.mount_type = MountType.GARAGE + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_sensor + + new_bootstrap.sensors = {new_sensor.id: new_sensor} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert ( + state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.GARAGE_DOOR.value + ) diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index 38c883c15c6..b586e5fbbfa 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -9,6 +9,7 @@ from unittest.mock import Mock import pytest from pyunifiprotect.data import NVR, Camera, Event, Sensor from pyunifiprotect.data.base import WifiConnectionState, WiredConnectionState +from pyunifiprotect.data.nvr import EventMetadata from pyunifiprotect.data.types import EventType, SmartDetectObjectType from homeassistant.components.unifiprotect.const import ( @@ -19,13 +20,18 @@ from homeassistant.components.unifiprotect.sensor import ( ALL_DEVICES_SENSORS, CAMERA_DISABLED_SENSORS, CAMERA_SENSORS, - DETECTED_OBJECT_NONE, MOTION_SENSORS, NVR_DISABLED_SENSORS, NVR_SENSORS, + OBJECT_TYPE_NONE, SENSE_SENSORS, ) -from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNKNOWN, Platform +from homeassistant.const import ( + ATTR_ATTRIBUTION, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -53,6 +59,10 @@ async def sensor_fixture( sensor_obj._api = mock_entry.api sensor_obj.name = "Test Sensor" sensor_obj.battery_status.percentage = 10.0 + sensor_obj.light_settings.is_enabled = True + sensor_obj.humidity_settings.is_enabled = True + sensor_obj.temperature_settings.is_enabled = True + sensor_obj.alarm_settings.is_enabled = True sensor_obj.stats.light.value = 10.0 sensor_obj.stats.humidity.value = 10.0 sensor_obj.stats.temperature.value = 10.0 @@ -68,7 +78,46 @@ async def sensor_fixture( await hass.async_block_till_done() # 2 from all, 4 from sense, 12 NVR - assert_entity_counts(hass, Platform.SENSOR, 18, 13) + assert_entity_counts(hass, Platform.SENSOR, 19, 14) + + yield sensor_obj + + Sensor.__config__.validate_assignment = True + + +@pytest.fixture(name="sensor_none") +async def sensor_none_fixture( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + mock_sensor: Sensor, + now: datetime, +): + """Fixture for a single sensor for testing the sensor platform.""" + + # disable pydantic validation so mocking can happen + Sensor.__config__.validate_assignment = False + + sensor_obj = mock_sensor.copy(deep=True) + sensor_obj._api = mock_entry.api + sensor_obj.name = "Test Sensor" + sensor_obj.battery_status.percentage = 10.0 + sensor_obj.light_settings.is_enabled = False + sensor_obj.humidity_settings.is_enabled = False + sensor_obj.temperature_settings.is_enabled = False + sensor_obj.alarm_settings.is_enabled = False + sensor_obj.up_since = now + sensor_obj.bluetooth_connection_state.signal_strength = -50.0 + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.sensors = { + sensor_obj.id: sensor_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + # 2 from all, 4 from sense, 12 NVR + assert_entity_counts(hass, Platform.SENSOR, 19, 14) yield sensor_obj @@ -131,7 +180,7 @@ async def test_sensor_setup_sensor( entity_registry = er.async_get(hass) - expected_values = ("10", "10.0", "10.0", "10.0") + expected_values = ("10", "10.0", "10.0", "10.0", "none") for index, description in enumerate(SENSE_SENSORS): unique_id, entity_id = ids_from_device_description( Platform.SENSOR, sensor, description @@ -164,6 +213,35 @@ async def test_sensor_setup_sensor( assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION +async def test_sensor_setup_sensor_none( + hass: HomeAssistant, mock_entry: MockEntityFixture, sensor_none: Sensor +): + """Test sensor entity setup for sensor devices with no sensors enabled.""" + + entity_registry = er.async_get(hass) + + expected_values = ( + "10", + STATE_UNAVAILABLE, + STATE_UNAVAILABLE, + STATE_UNAVAILABLE, + STATE_UNAVAILABLE, + ) + for index, description in enumerate(SENSE_SENSORS): + unique_id, entity_id = ids_from_device_description( + Platform.SENSOR, sensor_none, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == expected_values[index] + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + async def test_sensor_setup_nvr( hass: HomeAssistant, mock_entry: MockEntityFixture, now: datetime ): @@ -403,7 +481,7 @@ async def test_sensor_setup_camera( state = hass.states.get(entity_id) assert state - assert state.state == DETECTED_OBJECT_NONE + assert state.state == OBJECT_TYPE_NONE assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION assert state.attributes[ATTR_EVENT_SCORE] == 0 @@ -426,6 +504,7 @@ async def test_sensor_update_motion( smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], camera_id=camera.id, + api=mock_entry.api, ) new_bootstrap = copy(mock_entry.api.bootstrap) @@ -435,7 +514,7 @@ async def test_sensor_update_motion( mock_msg = Mock() mock_msg.changed_data = {} - mock_msg.new_obj = new_camera + mock_msg.new_obj = event new_bootstrap.cameras = {new_camera.id: new_camera} new_bootstrap.events = {event.id: event} @@ -448,3 +527,45 @@ async def test_sensor_update_motion( assert state.state == SmartDetectObjectType.PERSON.value assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION assert state.attributes[ATTR_EVENT_SCORE] == 100 + + +async def test_sensor_update_alarm( + hass: HomeAssistant, mock_entry: MockEntityFixture, sensor: Sensor, now: datetime +): + """Test sensor motion entity.""" + + _, entity_id = ids_from_device_description( + Platform.SENSOR, sensor, SENSE_SENSORS[4] + ) + + event_metadata = EventMetadata(sensor_id=sensor.id, alarm_type="smoke") + event = Event( + id="test_event_id", + type=EventType.SENSOR_ALARM, + start=now - timedelta(seconds=1), + end=None, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + metadata=event_metadata, + api=mock_entry.api, + ) + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_sensor = sensor.copy() + new_sensor.set_alarm_timeout() + new_sensor.last_alarm_event_id = event.id + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + + new_bootstrap.sensors = {new_sensor.id: new_sensor} + new_bootstrap.events = {event.id: event} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "smoke"