diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 6b8aa341dda..d3b164209ce 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from homematicip.base.enums import SmokeDetectorAlarmType, WindowState +from homematicip.base.enums import LockState, SmokeDetectorAlarmType, WindowState from homematicip.base.functionalChannels import MultiModeInputChannel from homematicip.device import ( AccelerationSensor, @@ -74,6 +74,30 @@ SAM_DEVICE_ATTRIBUTES = { } +def _is_full_flush_lock_controller(device: object) -> bool: + """Return whether the device is an HmIP-FLC.""" + return getattr(device, "modelType", None) == "HmIP-FLC" and hasattr( + device, "functionalChannels" + ) + + +def _get_channel_by_role( + device: object, + functional_channel_type: str, + channel_role: str, +) -> object | None: + """Return the matching functional channel for the device.""" + for channel in getattr(device, "functionalChannels", []): + channel_type = getattr(channel, "functionalChannelType", None) + channel_type_name = getattr(channel_type, "name", channel_type) + if channel_type_name != functional_channel_type: + continue + if getattr(channel, "channelRole", None) != channel_role: + continue + return channel + return None + + async def async_setup_entry( hass: HomeAssistant, config_entry: HomematicIPConfigEntry, @@ -122,6 +146,9 @@ async def async_setup_entry( entities.append( HomematicipPluggableMainsFailureSurveillanceSensor(hap, device) ) + if _is_full_flush_lock_controller(device): + entities.append(HomematicipFullFlushLockControllerLocked(hap, device)) + entities.append(HomematicipFullFlushLockControllerGlassBreak(hap, device)) if isinstance(device, PresenceDetectorIndoor): entities.append(HomematicipPresenceDetector(hap, device)) if isinstance(device, SmokeDetector): @@ -298,6 +325,55 @@ class HomematicipMotionDetector(HomematicipGenericEntity, BinarySensorEntity): return self._device.motionDetected +class HomematicipFullFlushLockControllerLocked( + HomematicipGenericEntity, BinarySensorEntity +): + """Representation of the HomematicIP full flush lock controller lock state.""" + + _attr_device_class = BinarySensorDeviceClass.LOCK + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the full flush lock controller lock sensor.""" + super().__init__(hap, device, post="Locked") + + @property + def is_on(self) -> bool: + """Return true if the controlled lock is locked.""" + channel = _get_channel_by_role( + self._device, + "MULTI_MODE_LOCK_INPUT_CHANNEL", + "DOOR_LOCK_SENSOR", + ) + if channel is None: + return False + lock_state = getattr(channel, "lockState", None) + return getattr(lock_state, "name", lock_state) == LockState.LOCKED.name + + +class HomematicipFullFlushLockControllerGlassBreak( + HomematicipGenericEntity, BinarySensorEntity +): + """Representation of the HomematicIP full flush lock controller glass state.""" + + _attr_device_class = BinarySensorDeviceClass.PROBLEM + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the full flush lock controller glass break sensor.""" + super().__init__(hap, device, post="Glass break") + + @property + def is_on(self) -> bool: + """Return true if glass break has been detected.""" + channel = _get_channel_by_role( + self._device, + "MULTI_MODE_LOCK_INPUT_CHANNEL", + "DOOR_LOCK_SENSOR", + ) + if channel is None: + return False + return bool(getattr(channel, "glassBroken", False)) + + class HomematicipPresenceDetector(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP presence detector.""" diff --git a/homeassistant/components/homematicip_cloud/button.py b/homeassistant/components/homematicip_cloud/button.py index 31fa2c889ac..bcd157d44d6 100644 --- a/homeassistant/components/homematicip_cloud/button.py +++ b/homeassistant/components/homematicip_cloud/button.py @@ -12,6 +12,13 @@ from .entity import HomematicipGenericEntity from .hap import HomematicIPConfigEntry, HomematicipHAP +def _is_full_flush_lock_controller(device: object) -> bool: + """Return whether the device is an HmIP-FLC.""" + return getattr(device, "modelType", None) == "HmIP-FLC" and hasattr( + device, "send_start_impulse_async" + ) + + async def async_setup_entry( hass: HomeAssistant, config_entry: HomematicIPConfigEntry, @@ -20,11 +27,17 @@ async def async_setup_entry( """Set up the HomematicIP button from a config entry.""" hap = config_entry.runtime_data - async_add_entities( + entities: list[ButtonEntity] = [ HomematicipGarageDoorControllerButton(hap, device) for device in hap.home.devices if isinstance(device, WallMountedGarageDoorController) + ] + entities.extend( + HomematicipFullFlushLockControllerButton(hap, device) + for device in hap.home.devices + if _is_full_flush_lock_controller(device) ) + async_add_entities(entities) class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEntity): @@ -38,3 +51,16 @@ class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEnti async def async_press(self) -> None: """Handle the button press.""" await self._device.send_start_impulse_async() + + +class HomematicipFullFlushLockControllerButton(HomematicipGenericEntity, ButtonEntity): + """Representation of the HomematicIP full flush lock controller opener.""" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the full flush lock controller opener button.""" + super().__init__(hap, device, post="Door opener") + self._attr_icon = "mdi:door-open" + + async def async_press(self) -> None: + """Handle the button press.""" + await self._device.send_start_impulse_async() diff --git a/tests/components/homematicip_cloud/conftest.py b/tests/components/homematicip_cloud/conftest.py index 8f6ed62fbfc..26e359422d0 100644 --- a/tests/components/homematicip_cloud/conftest.py +++ b/tests/components/homematicip_cloud/conftest.py @@ -1,5 +1,6 @@ """Initializer helpers for HomematicIP fake server.""" +from typing import Any from unittest.mock import AsyncMock, Mock, patch from homematicip.async_home import AsyncHome @@ -69,6 +70,124 @@ async def default_mock_hap_factory_fixture( return HomeFactory(hass, mock_connection, hmip_config_entry) +@pytest.fixture(name="full_flush_lock_controller_device_data") +def full_flush_lock_controller_device_data_fixture() -> dict[str, Any]: + """Return fixture data for an HmIP-FLC device.""" + return { + "availableFirmwareVersion": "0.0.0", + "connectionType": "HMIP_RF", + "deviceArchetype": "HMIP", + "firmwareVersion": "1.0.10", + "firmwareVersionInteger": 65546, + "functionalChannels": { + "0": { + "configPending": False, + "deviceId": "3014F7110000000000000026", + "dutyCycle": False, + "functionalChannelType": "DEVICE_BASE", + "groupIndex": 0, + "groups": [], + "index": 0, + "label": "", + "lowBat": None, + "routerModuleEnabled": False, + "routerModuleSupported": False, + "rssiDeviceValue": -82, + "rssiPeerValue": -97, + "supportedOptionalFeatures": { + "IFeatureRssiValue": True, + "IOptionalFeatureDutyCycle": True, + "IOptionalFeatureLowBat": False, + }, + "unreach": False, + }, + "1": { + "actionParameter": "NOT_CUSTOMISABLE", + "binaryBehaviorType": "NORMALLY_OPEN", + "channelRole": "DOOR_LOCK_SENSOR", + "corrosionPreventionActive": False, + "deviceId": "3014F7110000000000000026", + "doorBellSensorEventTimestamp": None, + "eventDelay": 0, + "functionalChannelType": "MULTI_MODE_LOCK_INPUT_CHANNEL", + "glassBroken": True, + "groupIndex": 1, + "groups": [], + "index": 1, + "label": "", + "lockState": "LOCKED", + "multiModeInputMode": "BINARY_BEHAVIOR", + "supportedOptionalFeatures": {}, + "windowState": "OPEN", + }, + "3": { + "channelRole": "DOOR_LOCK_ACTUATOR", + "deviceId": "3014F7110000000000000026", + "doorLockActive": False, + "functionalChannelType": "DOOR_SWITCH_CHANNEL", + "groupIndex": 3, + "groups": [], + "impulseDuration": 111600.0, + "index": 3, + "internalLinkConfiguration": { + "firstInputAction": "TOGGLE", + "internalLinkConfigurationType": "SINGLE_INPUT_DOOR_SWITCH", + }, + "label": "", + "multiModeInputMode": "KEY_BEHAVIOR", + "processing": False, + "profileMode": "AUTOMATIC", + "supportedOptionalFeatures": {}, + "userDesiredProfileMode": "AUTOMATIC", + }, + "4": { + "channelRole": "DOOR_OPENER_ACTUATOR", + "deviceId": "3014F7110000000000000026", + "doorLockActive": False, + "functionalChannelType": "DOOR_SWITCH_CHANNEL", + "groupIndex": 4, + "groups": [], + "impulseDuration": 0.9, + "index": 4, + "internalLinkConfiguration": { + "firstInputAction": "LOCK_OPEN", + "internalLinkConfigurationType": "SINGLE_INPUT_DOOR_SWITCH", + }, + "label": "", + "multiModeInputMode": "SWITCH_BEHAVIOR", + "processing": False, + "profileMode": "AUTOMATIC", + "supportedOptionalFeatures": {}, + "userDesiredProfileMode": "AUTOMATIC", + }, + "5": { + "authorized": True, + "channelRole": "DOOR_LOCK_ACTUATOR", + "deviceId": "3014F7110000000000000026", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 3, + "groups": [], + "index": 5, + "label": "", + "supportedOptionalFeatures": {}, + }, + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F7110000000000000026", + "label": "Universal Motorschloss Controller", + "lastStatusUpdate": 1760619002144, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manufacturerCode": 1, + "modelId": 546, + "modelType": "HmIP-FLC", + "oem": "eQ-3", + "permanentlyReachable": True, + "serializedGlobalTradeItemNumber": "3014F7110000000000000026", + "type": "FULL_FLUSH_LOCK_CONTROLLER", + "updateState": "UP_TO_DATE", + } + + @pytest.fixture(name="hmip_config") def hmip_config_fixture() -> ConfigType: """Create a config for homematic ip cloud.""" diff --git a/tests/components/homematicip_cloud/helper.py b/tests/components/homematicip_cloud/helper.py index d5083290bbd..63866964f03 100644 --- a/tests/components/homematicip_cloud/helper.py +++ b/tests/components/homematicip_cloud/helper.py @@ -109,7 +109,10 @@ class HomeFactory: self.hmip_config_entry = hmip_config_entry async def async_get_mock_hap( - self, test_devices=None, test_groups=None + self, + test_devices=None, + test_groups=None, + extra_devices: list[dict[str, Any]] | None = None, ) -> HomematicipHAP: """Create a mocked homematic access point.""" home_name = self.hmip_config_entry.data["name"] @@ -119,6 +122,7 @@ class HomeFactory: home_name=home_name, test_devices=test_devices, test_groups=test_groups, + extra_devices=extra_devices, ) .init_home() .get_async_home_mock() @@ -156,7 +160,12 @@ class HomeTemplate(Home): _typeSecurityEventMap = TYPE_SECURITY_EVENT_MAP def __init__( - self, connection=None, home_name="", test_devices=None, test_groups=None + self, + connection=None, + home_name="", + test_devices=None, + test_groups=None, + extra_devices: list[dict[str, Any]] | None = None, ) -> None: """Init template with connection.""" super().__init__(connection=connection) @@ -166,8 +175,12 @@ class HomeTemplate(Home): self.init_json_state = None self.test_devices = test_devices self.test_groups = test_groups + self.extra_devices = extra_devices or [] def _cleanup_json(self, json): + for extra_device in self.extra_devices: + json["devices"][extra_device["id"]] = extra_device + if self.test_devices is not None: new_devices = {} for json_device in json["devices"].items(): diff --git a/tests/components/homematicip_cloud/test_binary_sensor.py b/tests/components/homematicip_cloud/test_binary_sensor.py index e4aaa3e9b0b..b8602a81797 100644 --- a/tests/components/homematicip_cloud/test_binary_sensor.py +++ b/tests/components/homematicip_cloud/test_binary_sensor.py @@ -1,5 +1,7 @@ """Tests for HomematicIP Cloud binary sensor.""" +from typing import Any + from homematicip.base.enums import SmokeDetectorAlarmType, WindowState from homeassistant.components.homematicip_cloud.binary_sensor import ( @@ -27,6 +29,49 @@ from homeassistant.core import HomeAssistant from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics +async def test_hmip_full_flush_lock_controller_binary_sensors( + hass: HomeAssistant, + default_mock_hap_factory: HomeFactory, + full_flush_lock_controller_device_data: dict[str, Any], +) -> None: + """Test HomematicIP full flush lock controller binary sensors.""" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Universal Motorschloss Controller"], + extra_devices=[full_flush_lock_controller_device_data], + ) + + lock_entity_id = "binary_sensor.universal_motorschloss_controller_locked" + lock_state, hmip_device = get_and_check_entity_basics( + hass, + mock_hap, + lock_entity_id, + "Universal Motorschloss Controller Locked", + "HmIP-FLC", + ) + assert lock_state.state == STATE_ON + + glass_entity_id = "binary_sensor.universal_motorschloss_controller_glass_break" + glass_state, _ = get_and_check_entity_basics( + hass, + mock_hap, + glass_entity_id, + "Universal Motorschloss Controller Glass break", + "HmIP-FLC", + ) + assert glass_state.state == STATE_ON + + assert hmip_device is not None + await async_manipulate_test_data(hass, hmip_device, "lockState", "UNLOCKED") + lock_state = hass.states.get(lock_entity_id) + assert lock_state + assert lock_state.state == STATE_OFF + + await async_manipulate_test_data(hass, hmip_device, "glassBroken", False) + glass_state = hass.states.get(glass_entity_id) + assert glass_state + assert glass_state.state == STATE_OFF + + async def test_hmip_home_cloud_connection_sensor( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: diff --git a/tests/components/homematicip_cloud/test_button.py b/tests/components/homematicip_cloud/test_button.py index 7da86607096..a1eb06a8861 100644 --- a/tests/components/homematicip_cloud/test_button.py +++ b/tests/components/homematicip_cloud/test_button.py @@ -1,5 +1,7 @@ """Tests for HomematicIP Cloud button.""" +from typing import Any + from freezegun.api import FrozenDateTimeFactory from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS @@ -41,3 +43,41 @@ async def test_hmip_garage_door_controller_button( state = hass.states.get(entity_id) assert state assert state.state == now.isoformat() + + +async def test_hmip_full_flush_lock_controller_button( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + default_mock_hap_factory: HomeFactory, + full_flush_lock_controller_device_data: dict[str, Any], +) -> None: + """Test HomematicIP full flush lock controller opener button.""" + entity_id = "button.universal_motorschloss_controller_door_opener" + entity_name = "Universal Motorschloss Controller Door opener" + device_model = "HmIP-FLC" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Universal Motorschloss Controller"], + extra_devices=[full_flush_lock_controller_device_data], + ) + + get_and_check_entity_basics(hass, mock_hap, entity_id, entity_name, device_model) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + + now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") + freezer.move_to(now) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + hmip_device = mock_hap.hmip_device_by_entity_id[entity_id] + assert hmip_device.mock_calls[-1][0] == "send_start_impulse_async" + + state = hass.states.get(entity_id) + assert state + assert state.state == now.isoformat()