diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 6461d7d184d..a5b41a446fc 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -126,6 +126,16 @@ MOTION_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ) +DOORLOCK_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( + ProtectBinaryEntityDescription( + key="battery_low", + name="Battery low", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="battery_status.is_low", + ), +) + DISK_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( @@ -150,6 +160,7 @@ async def async_setup_entry( camera_descs=CAMERA_SENSORS, light_descs=LIGHT_SENSORS, sense_descs=SENSE_SENSORS, + lock_descs=DOORLOCK_SENSORS, ) entities += _async_motion_entities(data) entities += _async_nvr_entities(data) diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index 230f728e145..2e2aef39369 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -37,6 +37,7 @@ DEVICES_THAT_ADOPT = { ModelType.LIGHT, ModelType.VIEWPORT, ModelType.SENSOR, + ModelType.DOORLOCK, } DEVICES_WITH_ENTITIES = DEVICES_THAT_ADOPT | {ModelType.NVR} DEVICES_FOR_SUBSCRIBE = DEVICES_WITH_ENTITIES | {ModelType.EVENT} diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 7d3aeb6fff0..27b74d2b303 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -7,6 +7,7 @@ from typing import Any from pyunifiprotect.data import ( Camera, + Doorlock, Event, Light, ModelType, @@ -41,7 +42,7 @@ def _async_device_entities( entities: list[ProtectDeviceEntity] = [] for device in data.get_by_types({model_type}): - assert isinstance(device, (Camera, Light, Sensor, Viewer)) + assert isinstance(device, (Camera, Light, Sensor, Viewer, Doorlock)) for description in descs: assert isinstance(description, EntityDescription) if description.ufp_required_field: @@ -74,6 +75,7 @@ def async_all_device_entities( light_descs: Sequence[ProtectRequiredKeysMixin] | None = None, sense_descs: Sequence[ProtectRequiredKeysMixin] | None = None, viewer_descs: Sequence[ProtectRequiredKeysMixin] | None = None, + lock_descs: Sequence[ProtectRequiredKeysMixin] | None = None, all_descs: Sequence[ProtectRequiredKeysMixin] | None = None, ) -> list[ProtectDeviceEntity]: """Generate a list of all the device entities.""" @@ -82,12 +84,14 @@ def async_all_device_entities( light_descs = list(light_descs or []) + all_descs sense_descs = list(sense_descs or []) + all_descs viewer_descs = list(viewer_descs or []) + all_descs + lock_descs = list(lock_descs or []) + all_descs return ( _async_device_entities(data, klass, ModelType.CAMERA, camera_descs) + _async_device_entities(data, klass, ModelType.LIGHT, light_descs) + _async_device_entities(data, klass, ModelType.SENSOR, sense_descs) + _async_device_entities(data, klass, ModelType.VIEWPORT, viewer_descs) + + _async_device_entities(data, klass, ModelType.DOORLOCK, lock_descs) ) diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 7f47dfba7fb..9d0a2bd69bd 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -5,10 +5,11 @@ from dataclasses import dataclass from datetime import timedelta from typing import Generic -from pyunifiprotect.data.devices import Camera, Light +from pyunifiprotect.data.devices import Camera, Doorlock, Light from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry +from homeassistant.const import TIME_SECONDS from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -43,6 +44,14 @@ async def _set_pir_duration(obj: Light, value: float) -> None: await obj.set_duration(timedelta(seconds=value)) +def _get_auto_close(obj: Doorlock) -> int: + return int(obj.auto_close_time.total_seconds()) + + +async def _set_auto_close(obj: Doorlock, value: float) -> None: + await obj.set_auto_close_time(timedelta(seconds=value)) + + CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription( key="wdr_value", @@ -100,6 +109,7 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( name="Auto-shutoff Duration", icon="mdi:camera-timer", entity_category=EntityCategory.CONFIG, + unit_of_measurement=TIME_SECONDS, ufp_min=15, ufp_max=900, ufp_step=15, @@ -124,6 +134,22 @@ SENSE_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ), ) +DOORLOCK_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( + ProtectNumberEntityDescription[Doorlock]( + key="auto_lock_time", + name="Auto-lock Timeout", + icon="mdi:walk", + entity_category=EntityCategory.CONFIG, + unit_of_measurement=TIME_SECONDS, + ufp_min=0, + ufp_max=3600, + ufp_step=15, + ufp_required_field=None, + ufp_value_fn=_get_auto_close, + ufp_set_method_fn=_set_auto_close, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -138,6 +164,7 @@ async def async_setup_entry( camera_descs=CAMERA_NUMBERS, light_descs=LIGHT_NUMBERS, sense_descs=SENSE_NUMBERS, + lock_descs=DOORLOCK_NUMBERS, ) async_add_entities(entities) diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 83033a19ff4..d948222b5fd 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -12,14 +12,15 @@ from pyunifiprotect.api import ProtectApiClient from pyunifiprotect.data import ( Camera, DoorbellMessageType, + Doorlock, IRLEDMode, Light, LightModeEnableType, LightModeType, RecordingMode, + Sensor, Viewer, ) -from pyunifiprotect.data.devices import Sensor from pyunifiprotect.data.types import ChimeType, MountType import voluptuous as vol @@ -165,7 +166,7 @@ async def _set_light_mode(obj: Light, mode: str) -> None: ) -async def _set_paired_camera(obj: Light | Sensor, camera_id: str) -> None: +async def _set_paired_camera(obj: Light | Sensor | Doorlock, camera_id: str) -> None: if camera_id == TYPE_EMPTY_VALUE: camera: Camera | None = None else: @@ -276,6 +277,18 @@ SENSE_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ), ) +DOORLOCK_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( + ProtectSelectEntityDescription[Doorlock]( + key="paired_camera", + name="Paired Camera", + icon="mdi:cctv", + entity_category=EntityCategory.CONFIG, + ufp_value="camera_id", + ufp_options_fn=_get_paired_camera_options, + ufp_set_method_fn=_set_paired_camera, + ), +) + VIEWER_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription[Viewer]( key="viewer", @@ -303,6 +316,7 @@ async def async_setup_entry( light_descs=LIGHT_SELECTS, sense_descs=SENSE_SELECTS, viewer_descs=VIEWER_SELECTS, + lock_descs=DOORLOCK_SELECTS, ) async_add_entities(entities) diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 29be7ef96d6..330c90880ab 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -254,6 +254,18 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ) +DOORLOCK_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( + ProtectSensorEntityDescription( + key="battery_level", + name="Battery Level", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ufp_value="battery_status.percentage", + ), +) + NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription[ProtectDeviceModel]( key="uptime", @@ -400,6 +412,7 @@ async def async_setup_entry( all_descs=ALL_DEVICES_SENSORS, camera_descs=CAMERA_SENSORS + CAMERA_DISABLED_SENSORS, sense_descs=SENSE_SENSORS, + lock_descs=DOORLOCK_SENSORS, ) entities += _async_motion_entities(data) entities += _async_nvr_entities(data) diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index 7922dfbc19f..086bd852049 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -214,6 +214,17 @@ LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ) +DOORLOCK_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", + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -229,6 +240,7 @@ async def async_setup_entry( camera_descs=CAMERA_SWITCHES, light_descs=LIGHT_SWITCHES, sense_descs=SENSE_SWITCHES, + lock_descs=DOORLOCK_SWITCHES, ) async_add_entities(entities) diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index 32ffc6db951..f495b2bc8f7 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -11,10 +11,17 @@ from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest -from pyunifiprotect.data import Camera, Light, WSSubscriptionMessage +from pyunifiprotect.data import ( + NVR, + Camera, + Doorlock, + Light, + Liveview, + Sensor, + Viewer, + WSSubscriptionMessage, +) from pyunifiprotect.data.base import ProtectAdoptableDeviceModel -from pyunifiprotect.data.devices import Sensor, Viewer -from pyunifiprotect.data.nvr import NVR, Liveview from homeassistant.components.unifiprotect.const import DOMAIN from homeassistant.const import Platform @@ -41,6 +48,7 @@ class MockBootstrap: viewers: dict[str, Any] liveviews: dict[str, Any] events: dict[str, Any] + doorlocks: dict[str, Any] def reset_objects(self) -> None: """Reset all devices on bootstrap for tests.""" @@ -50,6 +58,7 @@ class MockBootstrap: self.viewers = {} self.liveviews = {} self.events = {} + self.doorlocks = {} def process_ws_packet(self, msg: WSSubscriptionMessage) -> None: """Fake process method for tests.""" @@ -117,6 +126,7 @@ def mock_bootstrap_fixture(mock_nvr: NVR): viewers={}, liveviews={}, events={}, + doorlocks={}, ) @@ -164,7 +174,7 @@ def mock_entry( @pytest.fixture def mock_liveview(): - """Mock UniFi Protect Camera device.""" + """Mock UniFi Protect Liveview.""" data = json.loads(load_fixture("sample_liveview.json", integration=DOMAIN)) return Liveview.from_unifi_dict(**data) @@ -180,7 +190,7 @@ def mock_camera(): @pytest.fixture def mock_light(): - """Mock UniFi Protect Camera device.""" + """Mock UniFi Protect Light device.""" data = json.loads(load_fixture("sample_light.json", integration=DOMAIN)) return Light.from_unifi_dict(**data) @@ -202,6 +212,14 @@ def mock_sensor(): return Sensor.from_unifi_dict(**data) +@pytest.fixture +def mock_doorlock(): + """Mock UniFi Protect Doorlock device.""" + + data = json.loads(load_fixture("sample_doorlock.json", integration=DOMAIN)) + return Doorlock.from_unifi_dict(**data) + + @pytest.fixture def now(): """Return datetime object that will be consistent throughout test.""" diff --git a/tests/components/unifiprotect/fixtures/sample_doorlock.json b/tests/components/unifiprotect/fixtures/sample_doorlock.json new file mode 100644 index 00000000000..babfe826763 --- /dev/null +++ b/tests/components/unifiprotect/fixtures/sample_doorlock.json @@ -0,0 +1,52 @@ +{ + "mac": "F10599AB6955", + "host": null, + "connectionHost": "192.168.102.63", + "type": "UFP-LOCK-R", + "name": "Wkltg Qcjxv", + "upSince": 1643050461849, + "uptime": null, + "lastSeen": 1643052750858, + "connectedSince": 1643052765849, + "state": "CONNECTED", + "hardwareRevision": 7, + "firmwareVersion": "1.2.0", + "latestFirmwareVersion": "1.2.0", + "firmwareBuild": null, + "isUpdating": false, + "isAdopting": false, + "isAdopted": true, + "isAdoptedByOther": false, + "isProvisioned": false, + "isRebooting": false, + "isSshEnabled": false, + "canAdopt": false, + "isAttemptingToConnect": false, + "credentials": "955756200c7f43936df9d5f7865f058e1528945aac0f0cb27cef960eb58f17db", + "lockStatus": "CLOSING", + "enableHomekit": false, + "autoCloseTimeMs": 15000, + "wiredConnectionState": { + "phyRate": null + }, + "ledSettings": { + "isEnabled": true + }, + "bluetoothConnectionState": { + "signalQuality": 62, + "signalStrength": -65 + }, + "batteryStatus": { + "percentage": 100, + "isLow": false + }, + "bridge": "61b3f5c90050a703e700042a", + "camera": "e2ff0ade6be0f2a2beb61869", + "bridgeCandidates": [], + "id": "1c812e80fd693ab51535be38", + "isConnected": true, + "hasHomekit": false, + "marketName": "UP DoorLock", + "modelKey": "doorlock", + "privateToken": "MsjIV0UUpMWuAQZvJnCOfC1K9UAfgqDKCIcWtANWIuW66OXLwSgMbNEG2MEkL2TViSkMbJvFxAQEyHU0EJeVCWzY6dGHGuKXFXZMqJWZivBGDC8JoXiRxNIBqHZtXQKXZIoXWKLmhBL7SDxLoFNYEYNNLUGKGFBBGX2oNLi8KRW3SDSUTTWJZNwAUs8GKeJJ" +} diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index d3dfc7b3405..f516ad64a0b 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -6,11 +6,12 @@ from datetime import timedelta from unittest.mock import AsyncMock, Mock import pytest -from pyunifiprotect.data import Camera, Light +from pyunifiprotect.data import Camera, Doorlock, Light from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.components.unifiprotect.number import ( CAMERA_NUMBERS, + DOORLOCK_NUMBERS, LIGHT_NUMBERS, ProtectNumberEntityDescription, ) @@ -93,6 +94,35 @@ async def camera_fixture( Camera.__config__.validate_assignment = True +@pytest.fixture(name="doorlock") +async def doorlock_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_doorlock: Doorlock +): + """Fixture for a single doorlock for testing the number platform.""" + + # disable pydantic validation so mocking can happen + Doorlock.__config__.validate_assignment = False + + lock_obj = mock_doorlock.copy(deep=True) + lock_obj._api = mock_entry.api + lock_obj.name = "Test Lock" + lock_obj.auto_close_time = timedelta(seconds=45) + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.doorlocks = { + lock_obj.id: lock_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.NUMBER, 1, 1) + + yield lock_obj + + Doorlock.__config__.validate_assignment = True + + async def test_number_setup_light( hass: HomeAssistant, light: Light, @@ -249,3 +279,20 @@ async def test_number_camera_simple( ) set_method.assert_called_once_with(1.0) + + +async def test_number_lock_auto_close(hass: HomeAssistant, doorlock: Doorlock): + """Test auto-lock timeout for locks.""" + + description = DOORLOCK_NUMBERS[0] + + doorlock.__fields__["set_auto_close_time"] = Mock() + doorlock.set_auto_close_time = AsyncMock() + + _, entity_id = ids_from_device_description(Platform.NUMBER, doorlock, description) + + await hass.services.async_call( + "number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 15.0}, blocking=True + ) + + doorlock.set_auto_close_time.assert_called_once_with(timedelta(seconds=15.0))