mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 11:17:21 +00:00
Add basic Doorlock support for UniFi protect (#64877)
This commit is contained in:
parent
2aaca346bd
commit
6cb9f0df2a
@ -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)
|
||||
|
@ -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}
|
||||
|
@ -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)
|
||||
)
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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."""
|
||||
|
52
tests/components/unifiprotect/fixtures/sample_doorlock.json
Normal file
52
tests/components/unifiprotect/fixtures/sample_doorlock.json
Normal 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"
|
||||
}
|
@ -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))
|
||||
|
Loading…
x
Reference in New Issue
Block a user