From ea4c7492dcb0c156452a104c861ade7ee1d04bb2 Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Fri, 8 May 2026 23:24:08 +0200 Subject: [PATCH] homematicip_cloud: fix HmIP-FLC lock state polarity The Locked binary sensor uses BinarySensorDeviceClass.LOCK, where the HA convention is ON = unlocked / open and OFF = locked / closed. The sensor returned ON when the API reports LOCKED, displaying the opposite of the actual lock state. Reported on hahn-th/homematicip-rest-api#606 by user dennisiser. Update the test fixture-derived expectations accordingly: the FLC fixture starts with lockState=LOCKED (now STATE_OFF) and the test manipulates it to UNLOCKED (now STATE_ON). --- .../homematicip_cloud/binary_sensor.py | 34 ++++++++++- .../homematicip_cloud/test_binary_sensor.py | 61 ++++++++++++++++++- 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 24bd75ea6f382d..6dae7505802a95 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -2,7 +2,12 @@ from typing import Any -from homematicip.base.enums import LockState, SmokeDetectorAlarmType, WindowState +from homematicip.base.enums import ( + BinaryBehaviorType, + LockState, + SmokeDetectorAlarmType, + WindowState, +) from homematicip.base.functionalChannels import MultiModeInputChannel from homematicip.device import ( AccelerationSensor, @@ -352,7 +357,22 @@ def __init__(self, hap: HomematicipHAP, device) -> None: @property def is_on(self) -> bool: - """Return true if the controlled lock is locked.""" + """Return true if the controlled lock is unlocked. + + Per HA's BinarySensorDeviceClass.LOCK contract, ON means + unlocked / open and OFF means locked / closed. + + The mapping from the firmware-reported ``lockState`` depends on + the channel's ``binaryBehaviorType``. With the default + ``NORMALLY_OPEN`` wiring, the input goes ACTIVE (and lockState + flips to ``LOCKED``) when the contact closes — i.e. when a + magnetic door contact registers the door as closed. With + ``NORMALLY_CLOSE`` the same physical event puts the input into + the IDLE state (lockState ``UNLOCKED``). To present the same + HA semantics regardless of which way the user wired the + contact, ``lockState`` is interpreted relative to the + configured behavior. + """ channel = _get_channel_by_role( self._device, "MULTI_MODE_LOCK_INPUT_CHANNEL", @@ -361,7 +381,15 @@ def is_on(self) -> bool: if channel is None: return False lock_state = getattr(channel, "lockState", None) - return getattr(lock_state, "name", lock_state) == LockState.LOCKED.name + is_locked_state = ( + getattr(lock_state, "name", lock_state) == LockState.LOCKED.name + ) + binary_behavior = getattr(channel, "binaryBehaviorType", None) + normally_close = ( + getattr(binary_behavior, "name", binary_behavior) + == BinaryBehaviorType.NORMALLY_CLOSE.name + ) + return is_locked_state if normally_close else not is_locked_state class HomematicipFullFlushLockControllerGlassBreak( diff --git a/tests/components/homematicip_cloud/test_binary_sensor.py b/tests/components/homematicip_cloud/test_binary_sensor.py index b8602a8179765b..44d542be7c08dc 100644 --- a/tests/components/homematicip_cloud/test_binary_sensor.py +++ b/tests/components/homematicip_cloud/test_binary_sensor.py @@ -2,7 +2,13 @@ from typing import Any -from homematicip.base.enums import SmokeDetectorAlarmType, WindowState +from homematicip.base.enums import ( + BinaryBehaviorType, + LockState, + SmokeDetectorAlarmType, + WindowState, +) +import pytest from homeassistant.components.homematicip_cloud.binary_sensor import ( ATTR_ACCELERATION_SENSOR_MODE, @@ -48,7 +54,9 @@ async def test_hmip_full_flush_lock_controller_binary_sensors( "Universal Motorschloss Controller Locked", "HmIP-FLC", ) - assert lock_state.state == STATE_ON + # Per BinarySensorDeviceClass.LOCK convention, ON means unlocked / open + # and OFF means locked / closed. Fixture initial state is LOCKED. + assert lock_state.state == STATE_OFF glass_entity_id = "binary_sensor.universal_motorschloss_controller_glass_break" glass_state, _ = get_and_check_entity_basics( @@ -64,7 +72,7 @@ async def test_hmip_full_flush_lock_controller_binary_sensors( 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 + assert lock_state.state == STATE_ON await async_manipulate_test_data(hass, hmip_device, "glassBroken", False) glass_state = hass.states.get(glass_entity_id) @@ -72,6 +80,53 @@ async def test_hmip_full_flush_lock_controller_binary_sensors( assert glass_state.state == STATE_OFF +@pytest.mark.parametrize( + ("binary_behavior", "lock_state", "expected_state"), + [ + # NORMALLY_OPEN: input ACTIVE (LOCKED) means contact closed = door + # closed in HA semantics → off; idle (UNLOCKED) = door open → on. + (BinaryBehaviorType.NORMALLY_OPEN, LockState.LOCKED, STATE_OFF), + (BinaryBehaviorType.NORMALLY_OPEN, LockState.UNLOCKED, STATE_ON), + # NORMALLY_CLOSE: polarity flipped — UNLOCKED reports the idle + # state (door closed) and LOCKED reports input active (door open). + (BinaryBehaviorType.NORMALLY_CLOSE, LockState.UNLOCKED, STATE_OFF), + (BinaryBehaviorType.NORMALLY_CLOSE, LockState.LOCKED, STATE_ON), + ], +) +async def test_hmip_full_flush_lock_controller_polarity( + hass: HomeAssistant, + default_mock_hap_factory: HomeFactory, + full_flush_lock_controller_device_data: dict[str, Any], + binary_behavior: BinaryBehaviorType, + lock_state: LockState, + expected_state: str, +) -> None: + """Test FLC lock state respects binaryBehaviorType for both wirings.""" + 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" + _, hmip_device = get_and_check_entity_basics( + hass, + mock_hap, + lock_entity_id, + "Universal Motorschloss Controller Locked", + "HmIP-FLC", + ) + assert hmip_device is not None + + await async_manipulate_test_data( + hass, hmip_device, "binaryBehaviorType", binary_behavior + ) + await async_manipulate_test_data(hass, hmip_device, "lockState", lock_state.name) + + state = hass.states.get(lock_entity_id) + assert state is not None + assert state.state == expected_state + + async def test_hmip_home_cloud_connection_sensor( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: