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, ...] = (
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)

View File

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

View File

@ -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)
)

View File

@ -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)

View File

@ -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)

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, ...] = (
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)

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(
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)

View File

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

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
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))