1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-17 23:53:49 +01:00

Add HmIP-FLC support to HomematicIP Cloud (#165827)

This commit is contained in:
Christian Lackas
2026-03-25 19:58:18 +01:00
committed by GitHub
parent bd298e92d0
commit 599f4f01d0
6 changed files with 323 additions and 4 deletions

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any 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.base.functionalChannels import MultiModeInputChannel
from homematicip.device import ( from homematicip.device import (
AccelerationSensor, 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: HomematicIPConfigEntry, config_entry: HomematicIPConfigEntry,
@@ -122,6 +146,9 @@ async def async_setup_entry(
entities.append( entities.append(
HomematicipPluggableMainsFailureSurveillanceSensor(hap, device) HomematicipPluggableMainsFailureSurveillanceSensor(hap, device)
) )
if _is_full_flush_lock_controller(device):
entities.append(HomematicipFullFlushLockControllerLocked(hap, device))
entities.append(HomematicipFullFlushLockControllerGlassBreak(hap, device))
if isinstance(device, PresenceDetectorIndoor): if isinstance(device, PresenceDetectorIndoor):
entities.append(HomematicipPresenceDetector(hap, device)) entities.append(HomematicipPresenceDetector(hap, device))
if isinstance(device, SmokeDetector): if isinstance(device, SmokeDetector):
@@ -298,6 +325,55 @@ class HomematicipMotionDetector(HomematicipGenericEntity, BinarySensorEntity):
return self._device.motionDetected 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): class HomematicipPresenceDetector(HomematicipGenericEntity, BinarySensorEntity):
"""Representation of the HomematicIP presence detector.""" """Representation of the HomematicIP presence detector."""

View File

@@ -12,6 +12,13 @@ from .entity import HomematicipGenericEntity
from .hap import HomematicIPConfigEntry, HomematicipHAP 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: HomematicIPConfigEntry, config_entry: HomematicIPConfigEntry,
@@ -20,11 +27,17 @@ async def async_setup_entry(
"""Set up the HomematicIP button from a config entry.""" """Set up the HomematicIP button from a config entry."""
hap = config_entry.runtime_data hap = config_entry.runtime_data
async_add_entities( entities: list[ButtonEntity] = [
HomematicipGarageDoorControllerButton(hap, device) HomematicipGarageDoorControllerButton(hap, device)
for device in hap.home.devices for device in hap.home.devices
if isinstance(device, WallMountedGarageDoorController) 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): class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEntity):
@@ -38,3 +51,16 @@ class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEnti
async def async_press(self) -> None: async def async_press(self) -> None:
"""Handle the button press.""" """Handle the button press."""
await self._device.send_start_impulse_async() 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()

View File

@@ -1,5 +1,6 @@
"""Initializer helpers for HomematicIP fake server.""" """Initializer helpers for HomematicIP fake server."""
from typing import Any
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
from homematicip.async_home import AsyncHome 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) 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") @pytest.fixture(name="hmip_config")
def hmip_config_fixture() -> ConfigType: def hmip_config_fixture() -> ConfigType:
"""Create a config for homematic ip cloud.""" """Create a config for homematic ip cloud."""

View File

@@ -109,7 +109,10 @@ class HomeFactory:
self.hmip_config_entry = hmip_config_entry self.hmip_config_entry = hmip_config_entry
async def async_get_mock_hap( 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: ) -> HomematicipHAP:
"""Create a mocked homematic access point.""" """Create a mocked homematic access point."""
home_name = self.hmip_config_entry.data["name"] home_name = self.hmip_config_entry.data["name"]
@@ -119,6 +122,7 @@ class HomeFactory:
home_name=home_name, home_name=home_name,
test_devices=test_devices, test_devices=test_devices,
test_groups=test_groups, test_groups=test_groups,
extra_devices=extra_devices,
) )
.init_home() .init_home()
.get_async_home_mock() .get_async_home_mock()
@@ -156,7 +160,12 @@ class HomeTemplate(Home):
_typeSecurityEventMap = TYPE_SECURITY_EVENT_MAP _typeSecurityEventMap = TYPE_SECURITY_EVENT_MAP
def __init__( 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: ) -> None:
"""Init template with connection.""" """Init template with connection."""
super().__init__(connection=connection) super().__init__(connection=connection)
@@ -166,8 +175,12 @@ class HomeTemplate(Home):
self.init_json_state = None self.init_json_state = None
self.test_devices = test_devices self.test_devices = test_devices
self.test_groups = test_groups self.test_groups = test_groups
self.extra_devices = extra_devices or []
def _cleanup_json(self, json): 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: if self.test_devices is not None:
new_devices = {} new_devices = {}
for json_device in json["devices"].items(): for json_device in json["devices"].items():

View File

@@ -1,5 +1,7 @@
"""Tests for HomematicIP Cloud binary sensor.""" """Tests for HomematicIP Cloud binary sensor."""
from typing import Any
from homematicip.base.enums import SmokeDetectorAlarmType, WindowState from homematicip.base.enums import SmokeDetectorAlarmType, WindowState
from homeassistant.components.homematicip_cloud.binary_sensor import ( 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 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( async def test_hmip_home_cloud_connection_sensor(
hass: HomeAssistant, default_mock_hap_factory: HomeFactory hass: HomeAssistant, default_mock_hap_factory: HomeFactory
) -> None: ) -> None:

View File

@@ -1,5 +1,7 @@
"""Tests for HomematicIP Cloud button.""" """Tests for HomematicIP Cloud button."""
from typing import Any
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS 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) state = hass.states.get(entity_id)
assert state assert state
assert state.state == now.isoformat() 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()