Skip to content

Commit

Permalink
fix(homekit): patch HomeKit ARMING_STATE to prevent ARMED_AWAY transi…
Browse files Browse the repository at this point in the history
…tion
  • Loading branch information
palazzem committed Mar 7, 2024
1 parent 92eec34 commit 8c1a4ca
Show file tree
Hide file tree
Showing 4 changed files with 116 additions and 5 deletions.
19 changes: 19 additions & 0 deletions custom_components/econnect_metronet/compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Detect if HomeKit integration dependencies are installed
try:
import pyhap # noqa: F401
from homeassistant.components.homekit.type_security_systems import (
HASS_TO_HOMEKIT_CURRENT,
HASS_TO_HOMEKIT_TARGET,
HK_ALARM_AWAY_ARMED,
HK_ALARM_STAY_ARMED,
)
except ModuleNotFoundError:
HASS_TO_HOMEKIT_TARGET = {}
HASS_TO_HOMEKIT_CURRENT = {}
HK_ALARM_AWAY_ARMED = None
HK_ALARM_STAY_ARMED = None

from homeassistant.const import STATE_ALARM_ARMING

# HomeKit Arming State
HK_ARMING_STATE = HASS_TO_HOMEKIT_TARGET.get(STATE_ALARM_ARMING)
22 changes: 20 additions & 2 deletions custom_components/econnect_metronet/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging

from elmo.api.exceptions import CodeError, LockError
from homeassistant.const import STATE_ALARM_ARMING

_LOGGER = logging.getLogger(__name__)

Expand All @@ -19,11 +20,24 @@ def set_device_state(new_state, loader_state):
def decorator(func):
@functools.wraps(func)
async def func_wrapper(*args, **kwargs):
# Set loader state
self = args[0]
previous_state = self._device.state
self._device.state = loader_state
self.async_write_ha_state()

# Set HomeKit transition to the ending state
# NOTE: this is a workaround required to avoid a bug in the HomeKit component,
# where the device moves from PREV_STATE -> AWAY -> NEXT_STATE.
from .compat import (
HASS_TO_HOMEKIT_CURRENT,
HASS_TO_HOMEKIT_TARGET,
HK_ARMING_STATE,
)

HASS_TO_HOMEKIT_TARGET[STATE_ALARM_ARMING] = HASS_TO_HOMEKIT_CURRENT.get(new_state)

try:
self.async_write_ha_state()
result = await func(*args, **kwargs)
self._device.state = new_state
self.async_write_ha_state()
Expand All @@ -34,7 +48,11 @@ async def func_wrapper(*args, **kwargs):
)
except CodeError:
_LOGGER.warning("Inserted code is not correct. Retry.")
# Reverting the state in case of any error
finally:
# Restore the original HomeKit arming state
HASS_TO_HOMEKIT_TARGET[STATE_ALARM_ARMING] = HK_ARMING_STATE

# Reverting previous state in case of errors
self._device.state = previous_state
self.async_write_ha_state()

Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ dev = [
"pytest-socket",
"requests-mock",
"syrupy",
# HomeKit testing
"HAP-python",
"fnv-hash-fast",
"ha-ffmpeg",
"numpy",
"pyqrcode",
]

lint = [
Expand Down
74 changes: 71 additions & 3 deletions tests/test_decorators.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
from importlib import reload

import pytest
from elmo.api.exceptions import CodeError, LockError

from homeassistant.const import STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMING

import custom_components.econnect_metronet.compat as compat_lib
from custom_components.econnect_metronet.compat import (
HASS_TO_HOMEKIT_TARGET,
HK_ALARM_AWAY_ARMED,
HK_ALARM_STAY_ARMED,
)
from custom_components.econnect_metronet.decorators import set_device_state


Expand All @@ -10,10 +19,10 @@ async def test_set_device_state_successful(panel):

@set_device_state("new_state", "loader_state")
async def test_func(self):
pass
return "value"

# Test
await test_func(panel)
assert await test_func(panel) == "value"
assert panel._device.state == "new_state"


Expand Down Expand Up @@ -56,3 +65,62 @@ async def test_func(self):

# Run test
await test_func(panel)


@pytest.mark.asyncio
async def test_set_device_state_homekit_transition(mocker, panel):
# Ensure HomeKit doesn't transition to ARM_AWAY state
# Regression test for: https://github.com/palazzem/ha-econnect-alarm/issues/154
assert HK_ALARM_AWAY_ARMED is not None, "pyhab test dependency is not installed. Test failed."

def async_write_ha_state():
assert HASS_TO_HOMEKIT_TARGET[STATE_ALARM_ARMING] == HK_ALARM_STAY_ARMED

@set_device_state(STATE_ALARM_ARMED_HOME, "loader_state")
async def test_func(self):
assert HASS_TO_HOMEKIT_TARGET[STATE_ALARM_ARMING] == HK_ALARM_STAY_ARMED

mocker.patch.object(panel, "async_write_ha_state", side_effect=async_write_ha_state)

# Test
await test_func(panel) == "value"
assert HASS_TO_HOMEKIT_TARGET[STATE_ALARM_ARMING] == HK_ALARM_AWAY_ARMED


@pytest.mark.asyncio
async def test_set_device_state_homekit_restore(panel):
# Ensure HomeKit target transition is restored if an exception happens
# Regression test for: https://github.com/palazzem/ha-econnect-alarm/issues/154
assert HK_ALARM_AWAY_ARMED is not None, "pyhab test dependency is not installed. Test failed."

@set_device_state(STATE_ALARM_ARMED_HOME, "loader_state")
async def test_func(self):
raise Exception()

# Test
with pytest.raises(Exception):
await test_func(panel)
assert HASS_TO_HOMEKIT_TARGET[STATE_ALARM_ARMING] == HK_ALARM_AWAY_ARMED


def test_homekit_not_installed(mocker):
# Ensure the compat module handles the case where HomeKit is not available
mocker.patch.dict(
"sys.modules",
{
"pyhap": None,
},
)
reload(compat_lib)
# Test
from custom_components.econnect_metronet.compat import (
HASS_TO_HOMEKIT_CURRENT,
HASS_TO_HOMEKIT_TARGET,
HK_ALARM_AWAY_ARMED,
HK_ALARM_STAY_ARMED,
)

assert HASS_TO_HOMEKIT_TARGET == {}
assert HASS_TO_HOMEKIT_CURRENT == {}
assert HK_ALARM_AWAY_ARMED is None
assert HK_ALARM_STAY_ARMED is None

0 comments on commit 8c1a4ca

Please sign in to comment.