From c9dfa15ed611340ed30045486e207ee93247b890 Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Sun, 26 Feb 2023 18:49:25 +0100 Subject: [PATCH] Add device HmIP-DLD (#83380) * Add HmIP-DLD * Remove commented code * Fix errors * Format using black * Fix device count * Add missing tests * Apply changes by reviewer * Change setup entry code * Remove jammed state * Add error messages * Update homeassistant/components/homematicip_cloud/helpers.py Co-authored-by: Aaron Bach * Add decorator * Add error log output * Update test_device.py --------- Co-authored-by: Aaron Bach --- .../components/homematicip_cloud/const.py | 1 + .../components/homematicip_cloud/helpers.py | 39 ++++ .../components/homematicip_cloud/lock.py | 95 ++++++++++ .../homematicip_cloud/test_device.py | 2 +- .../homematicip_cloud/test_helpers.py | 18 ++ .../components/homematicip_cloud/test_lock.py | 127 +++++++++++++ tests/fixtures/homematicip_cloud.json | 177 +++++++++++++++++- 7 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/homematicip_cloud/helpers.py create mode 100644 homeassistant/components/homematicip_cloud/lock.py create mode 100644 tests/components/homematicip_cloud/test_helpers.py create mode 100644 tests/components/homematicip_cloud/test_lock.py diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index 055db90a68c..4ea1a2fc7e0 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -14,6 +14,7 @@ PLATFORMS = [ Platform.CLIMATE, Platform.COVER, Platform.LIGHT, + Platform.LOCK, Platform.SENSOR, Platform.SWITCH, Platform.WEATHER, diff --git a/homeassistant/components/homematicip_cloud/helpers.py b/homeassistant/components/homematicip_cloud/helpers.py new file mode 100644 index 00000000000..1680904bbca --- /dev/null +++ b/homeassistant/components/homematicip_cloud/helpers.py @@ -0,0 +1,39 @@ +"""Helper functions for Homematicip Cloud Integration.""" + +from functools import wraps +import json +import logging + +from homeassistant.exceptions import HomeAssistantError + +from . import HomematicipGenericEntity + +_LOGGER = logging.getLogger(__name__) + + +def is_error_response(response) -> bool: + """Response from async call contains errors or not.""" + if isinstance(response, dict): + return response.get("errorCode") not in ("", None) + + return False + + +def handle_errors(func): + """Handle async errors.""" + + @wraps(func) + async def inner(self: HomematicipGenericEntity) -> None: + """Handle errors from async call.""" + result = await func(self) + if is_error_response(result): + _LOGGER.error( + "Error while execute function %s: %s", + __name__, + json.dumps(result), + ) + raise HomeAssistantError( + f"Error while execute function {func.__name__}: {result.get('errorCode')}. See log for more information." + ) + + return inner diff --git a/homeassistant/components/homematicip_cloud/lock.py b/homeassistant/components/homematicip_cloud/lock.py new file mode 100644 index 00000000000..563f0103060 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/lock.py @@ -0,0 +1,95 @@ +"""Support for HomematicIP Cloud lock devices.""" +from __future__ import annotations + +import logging +from typing import Any + +from homematicip.aio.device import AsyncDoorLockDrive +from homematicip.base.enums import LockState, MotorState + +from homeassistant.components.lock import LockEntity, LockEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericEntity +from .helpers import handle_errors + +_LOGGER = logging.getLogger(__name__) + +ATTR_AUTO_RELOCK_DELAY = "auto_relock_delay" +ATTR_DOOR_HANDLE_TYPE = "door_handle_type" +ATTR_DOOR_LOCK_DIRECTION = "door_lock_direction" +ATTR_DOOR_LOCK_NEUTRAL_POSITION = "door_lock_neutral_position" +ATTR_DOOR_LOCK_TURNS = "door_lock_turns" + +DEVICE_DLD_ATTRIBUTES = { + "autoRelockDelay": ATTR_AUTO_RELOCK_DELAY, + "doorHandleType": ATTR_DOOR_HANDLE_TYPE, + "doorLockDirection": ATTR_DOOR_LOCK_DIRECTION, + "doorLockNeutralPosition": ATTR_DOOR_LOCK_NEUTRAL_POSITION, + "doorLockTurns": ATTR_DOOR_LOCK_TURNS, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the HomematicIP locks from a config entry.""" + hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + + async_add_entities( + HomematicipDoorLockDrive(hap, device) + for device in hap.home.devices + if isinstance(device, AsyncDoorLockDrive) + ) + + +class HomematicipDoorLockDrive(HomematicipGenericEntity, LockEntity): + """Representation of the HomematicIP DoorLockDrive.""" + + _attr_supported_features = LockEntityFeature.OPEN + + @property + def is_locked(self) -> bool | None: + """Return true if device is locked.""" + return ( + self._device.lockState == LockState.LOCKED + and self._device.motorState == MotorState.STOPPED + ) + + @property + def is_locking(self) -> bool: + """Return true if device is locking.""" + return self._device.motorState == MotorState.CLOSING + + @property + def is_unlocking(self) -> bool: + """Return true if device is unlocking.""" + return self._device.motorState == MotorState.OPENING + + @handle_errors + async def async_lock(self, **kwargs: Any) -> None: + """Lock the device.""" + return await self._device.set_lock_state(LockState.LOCKED) + + @handle_errors + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the device.""" + return await self._device.set_lock_state(LockState.UNLOCKED) + + @handle_errors + async def async_open(self, **kwargs: Any) -> None: + """Open the door latch.""" + return await self._device.set_lock_state(LockState.OPEN) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the device.""" + return super().extra_state_attributes | { + attr_key: attr_value + for attr, attr_key in DEVICE_DLD_ATTRIBUTES.items() + if (attr_value := getattr(self._device, attr, None)) is not None + } diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 60d8c4d6554..d84fe690df6 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -25,7 +25,7 @@ async def test_hmip_load_all_supported_devices( test_devices=None, test_groups=None ) - assert len(mock_hap.hmip_device_by_entity_id) == 270 + assert len(mock_hap.hmip_device_by_entity_id) == 272 async def test_hmip_remove_device( diff --git a/tests/components/homematicip_cloud/test_helpers.py b/tests/components/homematicip_cloud/test_helpers.py new file mode 100644 index 00000000000..85c16255d71 --- /dev/null +++ b/tests/components/homematicip_cloud/test_helpers.py @@ -0,0 +1,18 @@ +"""Test HomematicIP Cloud helper functions.""" + +import json + +from homeassistant.components.homematicip_cloud.helpers import is_error_response + + +async def test_is_error_response(): + """Test, if an response is a normal result or an error.""" + assert not is_error_response("True") + assert not is_error_response(True) + assert not is_error_response("") + assert is_error_response( + json.loads( + '{"errorCode": "INVALID_NUMBER_PARAMETER_VALUE", "minValue": 0.0, "maxValue": 1.01}' + ) + ) + assert not is_error_response(json.loads('{"errorCode": ""}')) diff --git a/tests/components/homematicip_cloud/test_lock.py b/tests/components/homematicip_cloud/test_lock.py new file mode 100644 index 00000000000..48ae02738a6 --- /dev/null +++ b/tests/components/homematicip_cloud/test_lock.py @@ -0,0 +1,127 @@ +"""Tests for HomematicIP Cloud locks.""" +from unittest.mock import patch + +from homematicip.base.enums import LockState, MotorState +import pytest + +from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN +from homeassistant.components.lock import ( + DOMAIN, + STATE_LOCKING, + STATE_UNLOCKING, + LockEntityFeature, +) +from homeassistant.const import ATTR_SUPPORTED_FEATURES +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component + +from .helper import async_manipulate_test_data, get_and_check_entity_basics + + +async def test_manually_configured_platform(hass): + """Test that we do not set up an access point.""" + assert await async_setup_component( + hass, DOMAIN, {DOMAIN: {"platform": HMIPC_DOMAIN}} + ) + assert not hass.data.get(HMIPC_DOMAIN) + + +async def test_hmip_doorlockdrive(hass, default_mock_hap_factory): + """Test HomematicipDoorLockDrive.""" + entity_id = "lock.haustuer" + entity_name = "Haustuer" + device_model = "HmIP-DLD" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=[entity_name] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.attributes[ATTR_SUPPORTED_FEATURES] == LockEntityFeature.OPEN + + await hass.services.async_call( + "lock", + "open", + {"entity_id": entity_id}, + blocking=True, + ) + assert hmip_device.mock_calls[-1][0] == "set_lock_state" + assert hmip_device.mock_calls[-1][1] == (LockState.OPEN,) + + await hass.services.async_call( + "lock", + "lock", + {"entity_id": entity_id}, + blocking=True, + ) + assert hmip_device.mock_calls[-1][0] == "set_lock_state" + assert hmip_device.mock_calls[-1][1] == (LockState.LOCKED,) + + await hass.services.async_call( + "lock", + "unlock", + {"entity_id": entity_id}, + blocking=True, + ) + + assert hmip_device.mock_calls[-1][0] == "set_lock_state" + assert hmip_device.mock_calls[-1][1] == (LockState.UNLOCKED,) + + await async_manipulate_test_data( + hass, hmip_device, "motorState", MotorState.CLOSING + ) + ha_state = hass.states.get(entity_id) + assert ha_state.state == STATE_LOCKING + + await async_manipulate_test_data( + hass, hmip_device, "motorState", MotorState.OPENING + ) + ha_state = hass.states.get(entity_id) + assert ha_state.state == STATE_UNLOCKING + + +async def test_hmip_doorlockdrive_handle_errors(hass, default_mock_hap_factory): + """Test HomematicipDoorLockDrive.""" + entity_id = "lock.haustuer" + entity_name = "Haustuer" + device_model = "HmIP-DLD" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=[entity_name] + ) + with patch( + "homematicip.aio.device.AsyncDoorLockDrive.set_lock_state", + return_value={ + "errorCode": "INVALID_NUMBER_PARAMETER_VALUE", + "minValue": 0.0, + "maxValue": 1.01, + }, + ): + get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "lock", + "open", + {"entity_id": entity_id}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "lock", + "lock", + {"entity_id": entity_id}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "lock", + "unlock", + {"entity_id": entity_id}, + blocking=True, + ) diff --git a/tests/fixtures/homematicip_cloud.json b/tests/fixtures/homematicip_cloud.json index c54327069a2..d050300971c 100644 --- a/tests/fixtures/homematicip_cloud.json +++ b/tests/fixtures/homematicip_cloud.json @@ -6978,7 +6978,7 @@ "supported": true, "type": "EXTERNAL" }, - "3014F711A000DIN_RAIL_DIMMER3": { +"3014F711A000DIN_RAIL_DIMMER3": { "availableFirmwareVersion": "1.2.0", "connectionType": "HMIP_RF", "firmwareVersion": "1.2.0", @@ -7119,6 +7119,181 @@ "serializedGlobalTradeItemNumber": "3014F711A000DIN_RAIL_DIMMER3", "type": "DIN_RAIL_DIMMER_3", "updateState": "UP_TO_DATE" + }, + "3014F7110000000000000DLD": { + "availableFirmwareVersion": "1.2.0", + "connectionType": "HMIP_RF", + "firmwareVersion": "1.2.0", + "firmwareVersionInteger": 66048, + "functionalChannels": { + "0": { + "busConfigMismatch": null, + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "deviceId": "3014F7110000000000000DLD", + "deviceOverheated": false, + "deviceOverloaded": false, + "devicePowerFailureDetected": false, + "deviceUndervoltage": false, + "dutyCycle": false, + "functionalChannelType": "DEVICE_OPERATIONLOCK", + "groupIndex": 0, + "groups": ["00000000-0000-0000-0000-000000000025"], + "index": 0, + "label": "", + "lowBat": false, + "mountingOrientation": null, + "multicastRoutingEnabled": false, + "operationLockActive": false, + "particulateMatterSensorCommunicationError": null, + "particulateMatterSensorError": null, + "powerShortCircuit": null, + "profilePeriodLimitReached": false, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -63, + "rssiPeerValue": -64, + "shortCircuitDataLine": null, + "supportedOptionalFeatures": { + "IFeatureBusConfigMismatch": false, + "IFeatureDeviceCoProError": false, + "IFeatureDeviceCoProRestart": false, + "IFeatureDeviceCoProUpdate": false, + "IFeatureDeviceIdentify": false, + "IFeatureDeviceOverheated": false, + "IFeatureDeviceOverloaded": false, + "IFeatureDeviceParticulateMatterSensorCommunicationError": false, + "IFeatureDeviceParticulateMatterSensorError": false, + "IFeatureDevicePowerFailure": false, + "IFeatureDeviceTemperatureHumiditySensorCommunicationError": false, + "IFeatureDeviceTemperatureHumiditySensorError": false, + "IFeatureDeviceTemperatureOutOfRange": false, + "IFeatureDeviceUndervoltage": false, + "IFeatureMulticastRouter": false, + "IFeaturePowerShortCircuit": false, + "IFeatureProfilePeriodLimit": true, + "IFeatureRssiValue": true, + "IFeatureShortCircuitDataLine": false, + "IOptionalFeatureDutyCycle": true, + "IOptionalFeatureLowBat": true, + "IOptionalFeatureMountingOrientation": false + }, + "temperatureHumiditySensorCommunicationError": null, + "temperatureHumiditySensorError": null, + "temperatureOutOfRange": false, + "unreach": false + }, + "1": { + "autoRelockDelay": 300.0, + "autoRelockEnabled": false, + "deviceId": "3014F7110000000000000DLD", + "doorHandleType": "LEVER_HANDLE", + "doorLockDirection": "RIGHT", + "doorLockNeutralPosition": "VERTICAL", + "doorLockTurns": 2, + "functionalChannelType": "DOOR_LOCK_CHANNEL", + "groupIndex": 1, + "groups": [ + "00000000-0000-0000-0000-000000000026", + "00000000-0000-0000-0000-000000000027", + "00000000-0000-0000-0000-000000000028" + ], + "index": 1, + "label": "", + "lockState": "LOCKED", + "motorState": "STOPPED" + }, + "2": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 2, + "label": "" + }, + "3": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 3, + "label": "" + }, + "4": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 4, + "label": "" + }, + "5": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 5, + "label": "" + }, + "6": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 6, + "label": "" + }, + "7": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 7, + "label": "" + }, + "8": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 8, + "label": "" + }, + "9": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": [ + "00000000-0000-0000-0000-000000000033", + "00000000-0000-0000-0000-000000000032" + ], + "index": 9, + "label": "" + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F7110000000000000DLD", + "label": "Haustuer", + "lastStatusUpdate": 1618727020725, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manufacturerCode": 1, + "modelId": 423, + "modelType": "HmIP-DLD", + "oem": "eQ-3", + "permanentlyReachable": true, + "serializedGlobalTradeItemNumber": "3014F7110000000000000DLD", + "type": "DOOR_LOCK_DRIVE", + "updateState": "UP_TO_DATE" } }, "groups": {