Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions homeassistant/components/homematicip_cloud/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -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(
Expand Down
61 changes: 58 additions & 3 deletions tests/components/homematicip_cloud/test_binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -64,14 +72,61 @@ 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)
assert glass_state
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:
Expand Down