Add basic Doorlock support for UniFi protect (#64877)

This commit is contained in:
Christopher Bailey 2022-01-24 19:22:52 -05:00 committed by GitHub
parent 2aaca346bd
commit 6cb9f0df2a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 209 additions and 10 deletions

View File

@ -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, ...] = ( DISK_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
ProtectBinaryEntityDescription( ProtectBinaryEntityDescription(
@ -150,6 +160,7 @@ async def async_setup_entry(
camera_descs=CAMERA_SENSORS, camera_descs=CAMERA_SENSORS,
light_descs=LIGHT_SENSORS, light_descs=LIGHT_SENSORS,
sense_descs=SENSE_SENSORS, sense_descs=SENSE_SENSORS,
lock_descs=DOORLOCK_SENSORS,
) )
entities += _async_motion_entities(data) entities += _async_motion_entities(data)
entities += _async_nvr_entities(data) entities += _async_nvr_entities(data)

View File

@ -37,6 +37,7 @@ DEVICES_THAT_ADOPT = {
ModelType.LIGHT, ModelType.LIGHT,
ModelType.VIEWPORT, ModelType.VIEWPORT,
ModelType.SENSOR, ModelType.SENSOR,
ModelType.DOORLOCK,
} }
DEVICES_WITH_ENTITIES = DEVICES_THAT_ADOPT | {ModelType.NVR} DEVICES_WITH_ENTITIES = DEVICES_THAT_ADOPT | {ModelType.NVR}
DEVICES_FOR_SUBSCRIBE = DEVICES_WITH_ENTITIES | {ModelType.EVENT} DEVICES_FOR_SUBSCRIBE = DEVICES_WITH_ENTITIES | {ModelType.EVENT}

View File

@ -7,6 +7,7 @@ from typing import Any
from pyunifiprotect.data import ( from pyunifiprotect.data import (
Camera, Camera,
Doorlock,
Event, Event,
Light, Light,
ModelType, ModelType,
@ -41,7 +42,7 @@ def _async_device_entities(
entities: list[ProtectDeviceEntity] = [] entities: list[ProtectDeviceEntity] = []
for device in data.get_by_types({model_type}): 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: for description in descs:
assert isinstance(description, EntityDescription) assert isinstance(description, EntityDescription)
if description.ufp_required_field: if description.ufp_required_field:
@ -74,6 +75,7 @@ def async_all_device_entities(
light_descs: Sequence[ProtectRequiredKeysMixin] | None = None, light_descs: Sequence[ProtectRequiredKeysMixin] | None = None,
sense_descs: Sequence[ProtectRequiredKeysMixin] | None = None, sense_descs: Sequence[ProtectRequiredKeysMixin] | None = None,
viewer_descs: Sequence[ProtectRequiredKeysMixin] | None = None, viewer_descs: Sequence[ProtectRequiredKeysMixin] | None = None,
lock_descs: Sequence[ProtectRequiredKeysMixin] | None = None,
all_descs: Sequence[ProtectRequiredKeysMixin] | None = None, all_descs: Sequence[ProtectRequiredKeysMixin] | None = None,
) -> list[ProtectDeviceEntity]: ) -> list[ProtectDeviceEntity]:
"""Generate a list of all the device entities.""" """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 light_descs = list(light_descs or []) + all_descs
sense_descs = list(sense_descs or []) + all_descs sense_descs = list(sense_descs or []) + all_descs
viewer_descs = list(viewer_descs or []) + all_descs viewer_descs = list(viewer_descs or []) + all_descs
lock_descs = list(lock_descs or []) + all_descs
return ( return (
_async_device_entities(data, klass, ModelType.CAMERA, camera_descs) _async_device_entities(data, klass, ModelType.CAMERA, camera_descs)
+ _async_device_entities(data, klass, ModelType.LIGHT, light_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.SENSOR, sense_descs)
+ _async_device_entities(data, klass, ModelType.VIEWPORT, viewer_descs) + _async_device_entities(data, klass, ModelType.VIEWPORT, viewer_descs)
+ _async_device_entities(data, klass, ModelType.DOORLOCK, lock_descs)
) )

View File

@ -5,10 +5,11 @@ from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from typing import Generic 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.components.number import NumberEntity, NumberEntityDescription
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import TIME_SECONDS
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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)) 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, ...] = ( CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = (
ProtectNumberEntityDescription( ProtectNumberEntityDescription(
key="wdr_value", key="wdr_value",
@ -100,6 +109,7 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = (
name="Auto-shutoff Duration", name="Auto-shutoff Duration",
icon="mdi:camera-timer", icon="mdi:camera-timer",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
unit_of_measurement=TIME_SECONDS,
ufp_min=15, ufp_min=15,
ufp_max=900, ufp_max=900,
ufp_step=15, 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -138,6 +164,7 @@ async def async_setup_entry(
camera_descs=CAMERA_NUMBERS, camera_descs=CAMERA_NUMBERS,
light_descs=LIGHT_NUMBERS, light_descs=LIGHT_NUMBERS,
sense_descs=SENSE_NUMBERS, sense_descs=SENSE_NUMBERS,
lock_descs=DOORLOCK_NUMBERS,
) )
async_add_entities(entities) async_add_entities(entities)

View File

@ -12,14 +12,15 @@ from pyunifiprotect.api import ProtectApiClient
from pyunifiprotect.data import ( from pyunifiprotect.data import (
Camera, Camera,
DoorbellMessageType, DoorbellMessageType,
Doorlock,
IRLEDMode, IRLEDMode,
Light, Light,
LightModeEnableType, LightModeEnableType,
LightModeType, LightModeType,
RecordingMode, RecordingMode,
Sensor,
Viewer, Viewer,
) )
from pyunifiprotect.data.devices import Sensor
from pyunifiprotect.data.types import ChimeType, MountType from pyunifiprotect.data.types import ChimeType, MountType
import voluptuous as vol 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: if camera_id == TYPE_EMPTY_VALUE:
camera: Camera | None = None camera: Camera | None = None
else: 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, ...] = ( VIEWER_SELECTS: tuple[ProtectSelectEntityDescription, ...] = (
ProtectSelectEntityDescription[Viewer]( ProtectSelectEntityDescription[Viewer](
key="viewer", key="viewer",
@ -303,6 +316,7 @@ async def async_setup_entry(
light_descs=LIGHT_SELECTS, light_descs=LIGHT_SELECTS,
sense_descs=SENSE_SELECTS, sense_descs=SENSE_SELECTS,
viewer_descs=VIEWER_SELECTS, viewer_descs=VIEWER_SELECTS,
lock_descs=DOORLOCK_SELECTS,
) )
async_add_entities(entities) async_add_entities(entities)

View File

@ -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, ...] = ( NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
ProtectSensorEntityDescription[ProtectDeviceModel]( ProtectSensorEntityDescription[ProtectDeviceModel](
key="uptime", key="uptime",
@ -400,6 +412,7 @@ async def async_setup_entry(
all_descs=ALL_DEVICES_SENSORS, all_descs=ALL_DEVICES_SENSORS,
camera_descs=CAMERA_SENSORS + CAMERA_DISABLED_SENSORS, camera_descs=CAMERA_SENSORS + CAMERA_DISABLED_SENSORS,
sense_descs=SENSE_SENSORS, sense_descs=SENSE_SENSORS,
lock_descs=DOORLOCK_SENSORS,
) )
entities += _async_motion_entities(data) entities += _async_motion_entities(data)
entities += _async_nvr_entities(data) entities += _async_nvr_entities(data)

View File

@ -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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -229,6 +240,7 @@ async def async_setup_entry(
camera_descs=CAMERA_SWITCHES, camera_descs=CAMERA_SWITCHES,
light_descs=LIGHT_SWITCHES, light_descs=LIGHT_SWITCHES,
sense_descs=SENSE_SWITCHES, sense_descs=SENSE_SWITCHES,
lock_descs=DOORLOCK_SWITCHES,
) )
async_add_entities(entities) async_add_entities(entities)

View File

@ -11,10 +11,17 @@ from typing import Any
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
import pytest 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.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.components.unifiprotect.const import DOMAIN
from homeassistant.const import Platform from homeassistant.const import Platform
@ -41,6 +48,7 @@ class MockBootstrap:
viewers: dict[str, Any] viewers: dict[str, Any]
liveviews: dict[str, Any] liveviews: dict[str, Any]
events: dict[str, Any] events: dict[str, Any]
doorlocks: dict[str, Any]
def reset_objects(self) -> None: def reset_objects(self) -> None:
"""Reset all devices on bootstrap for tests.""" """Reset all devices on bootstrap for tests."""
@ -50,6 +58,7 @@ class MockBootstrap:
self.viewers = {} self.viewers = {}
self.liveviews = {} self.liveviews = {}
self.events = {} self.events = {}
self.doorlocks = {}
def process_ws_packet(self, msg: WSSubscriptionMessage) -> None: def process_ws_packet(self, msg: WSSubscriptionMessage) -> None:
"""Fake process method for tests.""" """Fake process method for tests."""
@ -117,6 +126,7 @@ def mock_bootstrap_fixture(mock_nvr: NVR):
viewers={}, viewers={},
liveviews={}, liveviews={},
events={}, events={},
doorlocks={},
) )
@ -164,7 +174,7 @@ def mock_entry(
@pytest.fixture @pytest.fixture
def mock_liveview(): def mock_liveview():
"""Mock UniFi Protect Camera device.""" """Mock UniFi Protect Liveview."""
data = json.loads(load_fixture("sample_liveview.json", integration=DOMAIN)) data = json.loads(load_fixture("sample_liveview.json", integration=DOMAIN))
return Liveview.from_unifi_dict(**data) return Liveview.from_unifi_dict(**data)
@ -180,7 +190,7 @@ def mock_camera():
@pytest.fixture @pytest.fixture
def mock_light(): def mock_light():
"""Mock UniFi Protect Camera device.""" """Mock UniFi Protect Light device."""
data = json.loads(load_fixture("sample_light.json", integration=DOMAIN)) data = json.loads(load_fixture("sample_light.json", integration=DOMAIN))
return Light.from_unifi_dict(**data) return Light.from_unifi_dict(**data)
@ -202,6 +212,14 @@ def mock_sensor():
return Sensor.from_unifi_dict(**data) 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 @pytest.fixture
def now(): def now():
"""Return datetime object that will be consistent throughout test.""" """Return datetime object that will be consistent throughout test."""

View File

@ -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"
}

View File

@ -6,11 +6,12 @@ from datetime import timedelta
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
import pytest 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.const import DEFAULT_ATTRIBUTION
from homeassistant.components.unifiprotect.number import ( from homeassistant.components.unifiprotect.number import (
CAMERA_NUMBERS, CAMERA_NUMBERS,
DOORLOCK_NUMBERS,
LIGHT_NUMBERS, LIGHT_NUMBERS,
ProtectNumberEntityDescription, ProtectNumberEntityDescription,
) )
@ -93,6 +94,35 @@ async def camera_fixture(
Camera.__config__.validate_assignment = True 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( async def test_number_setup_light(
hass: HomeAssistant, hass: HomeAssistant,
light: Light, light: Light,
@ -249,3 +279,20 @@ async def test_number_camera_simple(
) )
set_method.assert_called_once_with(1.0) 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))