Improve lock handling in Yale Smart Living (#124245)

* Improve handling of locks in yalesmartalarm

* requirements

* fix coordinator setup

* Fix lock iteration

* Fix tests

* Fix review comments
This commit is contained in:
G Johansson 2024-09-20 23:07:52 +02:00 committed by GitHub
parent 3e1da876c6
commit 41c1cfcef0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 91 additions and 113 deletions

View File

@ -3,8 +3,9 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import timedelta
from typing import Any from typing import TYPE_CHECKING, Any
from yalesmartalarmclient import YaleLock
from yalesmartalarmclient.client import YaleSmartAlarmClient from yalesmartalarmclient.client import YaleSmartAlarmClient
from yalesmartalarmclient.exceptions import AuthenticationError from yalesmartalarmclient.exceptions import AuthenticationError
@ -32,6 +33,7 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
always_update=False, always_update=False,
) )
self.locks: list[YaleLock] = []
async def _async_setup(self) -> None: async def _async_setup(self) -> None:
"""Set up connection to Yale.""" """Set up connection to Yale."""
@ -41,6 +43,7 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.entry.data[CONF_USERNAME], self.entry.data[CONF_USERNAME],
self.entry.data[CONF_PASSWORD], self.entry.data[CONF_PASSWORD],
) )
self.locks = await self.hass.async_add_executor_job(self.yale.get_locks)
except AuthenticationError as error: except AuthenticationError as error:
raise ConfigEntryAuthFailed from error raise ConfigEntryAuthFailed from error
except YALE_BASE_ERRORS as error: except YALE_BASE_ERRORS as error:
@ -51,65 +54,11 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
updates = await self.hass.async_add_executor_job(self.get_updates) updates = await self.hass.async_add_executor_job(self.get_updates)
locks = []
door_windows = [] door_windows = []
temp_sensors = [] temp_sensors = []
for device in updates["cycle"]["device_status"]: for device in updates["cycle"]["device_status"]:
state = device["status1"] state = device["status1"]
if device["type"] == "device_type.door_lock":
lock_status_str = device["minigw_lock_status"]
lock_status = int(str(lock_status_str or 0), 16)
closed = (lock_status & 16) == 16
locked = (lock_status & 1) == 1
if not lock_status and "device_status.lock" in state:
device["_state"] = "locked"
device["_state2"] = "unknown"
locks.append(device)
continue
if not lock_status and "device_status.unlock" in state:
device["_state"] = "unlocked"
device["_state2"] = "unknown"
locks.append(device)
continue
if (
lock_status
and (
"device_status.lock" in state or "device_status.unlock" in state
)
and closed
and locked
):
device["_state"] = "locked"
device["_state2"] = "closed"
locks.append(device)
continue
if (
lock_status
and (
"device_status.lock" in state or "device_status.unlock" in state
)
and closed
and not locked
):
device["_state"] = "unlocked"
device["_state2"] = "closed"
locks.append(device)
continue
if (
lock_status
and (
"device_status.lock" in state or "device_status.unlock" in state
)
and not closed
):
device["_state"] = "unlocked"
device["_state2"] = "open"
locks.append(device)
continue
device["_state"] = "unavailable"
locks.append(device)
continue
if device["type"] == "device_type.door_contact": if device["type"] == "device_type.door_contact":
if "device_status.dc_close" in state: if "device_status.dc_close" in state:
device["_state"] = "closed" device["_state"] = "closed"
@ -128,19 +77,16 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
_sensor_map = { _sensor_map = {
contact["address"]: contact["_state"] for contact in door_windows contact["address"]: contact["_state"] for contact in door_windows
} }
_lock_map = {lock["address"]: lock["_state"] for lock in locks}
_temp_map = {temp["address"]: temp["status_temp"] for temp in temp_sensors} _temp_map = {temp["address"]: temp["status_temp"] for temp in temp_sensors}
return { return {
"alarm": updates["arm_status"], "alarm": updates["arm_status"],
"locks": locks,
"door_windows": door_windows, "door_windows": door_windows,
"temp_sensors": temp_sensors, "temp_sensors": temp_sensors,
"status": updates["status"], "status": updates["status"],
"online": updates["online"], "online": updates["online"],
"sensor_map": _sensor_map, "sensor_map": _sensor_map,
"temp_map": _temp_map, "temp_map": _temp_map,
"lock_map": _lock_map,
"panel_info": updates["panel_info"], "panel_info": updates["panel_info"],
} }
@ -149,6 +95,13 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
try: try:
arm_status = self.yale.get_armed_status() arm_status = self.yale.get_armed_status()
data = self.yale.get_information() data = self.yale.get_information()
if TYPE_CHECKING:
assert data.cycle
for device in data.cycle["data"]["device_status"]:
if device["type"] == YaleLock.DEVICE_TYPE:
for lock in self.locks:
if lock.name == device["name"]:
lock.update(device)
except AuthenticationError as error: except AuthenticationError as error:
raise ConfigEntryAuthFailed from error raise ConfigEntryAuthFailed from error
except YALE_BASE_ERRORS as error: except YALE_BASE_ERRORS as error:

View File

@ -1,5 +1,7 @@
"""Base class for yale_smart_alarm entity.""" """Base class for yale_smart_alarm entity."""
from yalesmartalarmclient import YaleLock
from homeassistant.const import CONF_NAME, CONF_USERNAME from homeassistant.const import CONF_NAME, CONF_USERNAME
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
@ -9,7 +11,7 @@ from .const import DOMAIN, MANUFACTURER, MODEL
from .coordinator import YaleDataUpdateCoordinator from .coordinator import YaleDataUpdateCoordinator
class YaleEntity(CoordinatorEntity[YaleDataUpdateCoordinator], Entity): class YaleEntity(CoordinatorEntity[YaleDataUpdateCoordinator]):
"""Base implementation for Yale device.""" """Base implementation for Yale device."""
_attr_has_entity_name = True _attr_has_entity_name = True
@ -23,7 +25,25 @@ class YaleEntity(CoordinatorEntity[YaleDataUpdateCoordinator], Entity):
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
model=MODEL, model=MODEL,
identifiers={(DOMAIN, data["address"])}, identifiers={(DOMAIN, data["address"])},
via_device=(DOMAIN, self.coordinator.entry.data[CONF_USERNAME]), via_device=(DOMAIN, coordinator.entry.data[CONF_USERNAME]),
)
class YaleLockEntity(CoordinatorEntity[YaleDataUpdateCoordinator]):
"""Base implementation for Yale lock device."""
_attr_has_entity_name = True
def __init__(self, coordinator: YaleDataUpdateCoordinator, lock: YaleLock) -> None:
"""Initialize an Yale device."""
super().__init__(coordinator)
self._attr_unique_id: str = lock.sid()
self._attr_device_info = DeviceInfo(
name=lock.name,
manufacturer=MANUFACTURER,
model=MODEL,
identifiers={(DOMAIN, lock.sid())},
via_device=(DOMAIN, coordinator.entry.data[CONF_USERNAME]),
) )

View File

@ -2,9 +2,16 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any from typing import Any
from homeassistant.components.lock import LockEntity from yalesmartalarmclient import YaleLock, YaleLockState
from homeassistant.components.lock import (
STATE_LOCKED,
STATE_OPEN,
STATE_UNLOCKED,
LockEntity,
)
from homeassistant.const import ATTR_CODE from homeassistant.const import ATTR_CODE
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
@ -18,7 +25,13 @@ from .const import (
YALE_ALL_ERRORS, YALE_ALL_ERRORS,
) )
from .coordinator import YaleDataUpdateCoordinator from .coordinator import YaleDataUpdateCoordinator
from .entity import YaleEntity from .entity import YaleLockEntity
LOCK_STATE_MAP = {
YaleLockState.LOCKED: STATE_LOCKED,
YaleLockState.UNLOCKED: STATE_UNLOCKED,
YaleLockState.DOOR_OPEN: STATE_OPEN,
}
async def async_setup_entry( async def async_setup_entry(
@ -30,68 +43,62 @@ async def async_setup_entry(
code_format = entry.options.get(CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS) code_format = entry.options.get(CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS)
async_add_entities( async_add_entities(
YaleDoorlock(coordinator, data, code_format) YaleDoorlock(coordinator, lock, code_format) for lock in coordinator.locks
for data in coordinator.data["locks"]
) )
class YaleDoorlock(YaleEntity, LockEntity): class YaleDoorlock(YaleLockEntity, LockEntity):
"""Representation of a Yale doorlock.""" """Representation of a Yale doorlock."""
_attr_name = None _attr_name = None
def __init__( def __init__(
self, coordinator: YaleDataUpdateCoordinator, data: dict, code_format: int self, coordinator: YaleDataUpdateCoordinator, lock: YaleLock, code_format: int
) -> None: ) -> None:
"""Initialize the Yale Lock Device.""" """Initialize the Yale Lock Device."""
super().__init__(coordinator, data) super().__init__(coordinator, lock)
self._attr_code_format = rf"^\d{{{code_format}}}$" self._attr_code_format = rf"^\d{{{code_format}}}$"
self.lock_name: str = data["name"] self.lock_data = lock
async def async_unlock(self, **kwargs: Any) -> None: async def async_unlock(self, **kwargs: Any) -> None:
"""Send unlock command.""" """Send unlock command."""
code: str | None = kwargs.get(ATTR_CODE) code: str | None = kwargs.get(ATTR_CODE)
return await self.async_set_lock("unlocked", code) return await self.async_set_lock(YaleLockState.UNLOCKED, code)
async def async_lock(self, **kwargs: Any) -> None: async def async_lock(self, **kwargs: Any) -> None:
"""Send lock command.""" """Send lock command."""
return await self.async_set_lock("locked", None) return await self.async_set_lock(YaleLockState.LOCKED, None)
async def async_set_lock(self, command: str, code: str | None) -> None: async def async_set_lock(self, state: YaleLockState, code: str | None) -> None:
"""Set lock.""" """Set lock."""
if TYPE_CHECKING: if state is YaleLockState.UNLOCKED and not code:
assert self.coordinator.yale, "Connection to API is missing"
if command == "unlocked" and not code:
raise ServiceValidationError( raise ServiceValidationError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="no_code", translation_key="no_code",
) )
lock_state = False
try: try:
get_lock = await self.hass.async_add_executor_job( if state is YaleLockState.LOCKED:
self.coordinator.yale.lock_api.get, self.lock_name
)
if get_lock and command == "locked":
lock_state = await self.hass.async_add_executor_job( lock_state = await self.hass.async_add_executor_job(
self.coordinator.yale.lock_api.close_lock, self.lock_data.close
get_lock,
) )
if code and get_lock and command == "unlocked": if code and state is YaleLockState.UNLOCKED:
lock_state = await self.hass.async_add_executor_job( lock_state = await self.hass.async_add_executor_job(
self.coordinator.yale.lock_api.open_lock, get_lock, code self.lock_data.open, code
) )
except YALE_ALL_ERRORS as error: except YALE_ALL_ERRORS as error:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_lock", translation_key="set_lock",
translation_placeholders={ translation_placeholders={
"name": self.lock_name, "name": self.lock_data.name,
"error": str(error), "error": str(error),
}, },
) from error ) from error
if lock_state: if lock_state:
self.coordinator.data["lock_map"][self._attr_unique_id] = command self.lock_data.set_state(state)
self.async_write_ha_state() self.async_write_ha_state()
return return
raise HomeAssistantError( raise HomeAssistantError(
@ -102,4 +109,9 @@ class YaleDoorlock(YaleEntity, LockEntity):
@property @property
def is_locked(self) -> bool | None: def is_locked(self) -> bool | None:
"""Return true if the lock is locked.""" """Return true if the lock is locked."""
return bool(self.coordinator.data["lock_map"][self._attr_unique_id] == "locked") return LOCK_STATE_MAP.get(self.lock_data.state()) == STATE_LOCKED
@property
def is_open(self) -> bool | None:
"""Return true if the lock is open."""
return LOCK_STATE_MAP.get(self.lock_data.state()) == STATE_OPEN

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/yale_smart_alarm", "documentation": "https://www.home-assistant.io/integrations/yale_smart_alarm",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["yalesmartalarmclient"], "loggers": ["yalesmartalarmclient"],
"requirements": ["yalesmartalarmclient==0.4.0"] "requirements": ["yalesmartalarmclient==0.4.2"]
} }

View File

@ -3002,7 +3002,7 @@ xmltodict==0.13.0
xs1-api-client==3.0.0 xs1-api-client==3.0.0
# homeassistant.components.yale_smart_alarm # homeassistant.components.yale_smart_alarm
yalesmartalarmclient==0.4.0 yalesmartalarmclient==0.4.2
# homeassistant.components.august # homeassistant.components.august
# homeassistant.components.yale # homeassistant.components.yale

View File

@ -2388,7 +2388,7 @@ xknxproject==3.7.1
xmltodict==0.13.0 xmltodict==0.13.0
# homeassistant.components.yale_smart_alarm # homeassistant.components.yale_smart_alarm
yalesmartalarmclient==0.4.0 yalesmartalarmclient==0.4.2
# homeassistant.components.august # homeassistant.components.august
# homeassistant.components.yale # homeassistant.components.yale

View File

@ -7,7 +7,7 @@ from typing import Any
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import pytest import pytest
from yalesmartalarmclient import YaleSmartAlarmData from yalesmartalarmclient import YaleDoorManAPI, YaleLock, YaleSmartAlarmData
from yalesmartalarmclient.const import YALE_STATE_ARM_FULL from yalesmartalarmclient.const import YALE_STATE_ARM_FULL
from homeassistant.components.yale_smart_alarm.const import DOMAIN, PLATFORMS from homeassistant.components.yale_smart_alarm.const import DOMAIN, PLATFORMS
@ -53,16 +53,28 @@ async def load_config_entry(
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
cycle = get_data.cycle["data"]
data = {"data": cycle["device_status"]}
with patch( with patch(
"homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient", "homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient",
autospec=True, autospec=True,
) as mock_client_class: ) as mock_client_class:
client = mock_client_class.return_value client = mock_client_class.return_value
client.auth = Mock() client.auth = Mock()
client.lock_api = Mock() client.auth.get_authenticated = Mock(return_value=data)
client.auth.post_authenticated = Mock(return_value={"code": "000"})
client.lock_api = YaleDoorManAPI(client.auth)
locks = [
YaleLock(device, lock_api=client.lock_api)
for device in cycle["device_status"]
if device["type"] == YaleLock.DEVICE_TYPE
]
client.get_locks.return_value = locks
client.get_all.return_value = get_all_data client.get_all.return_value = get_all_data
client.get_information.return_value = get_data client.get_information.return_value = get_data
client.get_armed_status.return_value = YALE_STATE_ARM_FULL client.get_armed_status.return_value = YALE_STATE_ARM_FULL
await hass.config_entries.async_setup(config_entry.entry_id) await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -78,7 +90,7 @@ def get_fixture_data() -> dict[str, Any]:
return json_data return json_data
@pytest.fixture(name="get_data", scope="package") @pytest.fixture(name="get_data")
def get_update_data(loaded_fixture: dict[str, Any]) -> YaleSmartAlarmData: def get_update_data(loaded_fixture: dict[str, Any]) -> YaleSmartAlarmData:
"""Load update data and return.""" """Load update data and return."""
@ -94,7 +106,7 @@ def get_update_data(loaded_fixture: dict[str, Any]) -> YaleSmartAlarmData:
) )
@pytest.fixture(name="get_all_data", scope="package") @pytest.fixture(name="get_all_data")
def get_diag_data(loaded_fixture: dict[str, Any]) -> YaleSmartAlarmData: def get_diag_data(loaded_fixture: dict[str, Any]) -> YaleSmartAlarmData:
"""Load all data and return.""" """Load all data and return."""

View File

@ -24,8 +24,6 @@
'capture_latest': None, 'capture_latest': None,
'device_status': list([ 'device_status': list([
dict({ dict({
'_state': 'locked',
'_state2': 'closed',
'address': '**REDACTED**', 'address': '**REDACTED**',
'area': '1', 'area': '1',
'bypass': '0', 'bypass': '0',
@ -86,8 +84,6 @@
'type_no': '72', 'type_no': '72',
}), }),
dict({ dict({
'_state': 'unlocked',
'_state2': 'unknown',
'address': '**REDACTED**', 'address': '**REDACTED**',
'area': '1', 'area': '1',
'bypass': '0', 'bypass': '0',
@ -147,8 +143,6 @@
'type_no': '72', 'type_no': '72',
}), }),
dict({ dict({
'_state': 'locked',
'_state2': 'unknown',
'address': '**REDACTED**', 'address': '**REDACTED**',
'area': '1', 'area': '1',
'bypass': '0', 'bypass': '0',
@ -391,8 +385,6 @@
'type_no': '4', 'type_no': '4',
}), }),
dict({ dict({
'_state': 'unlocked',
'_state2': 'closed',
'address': '**REDACTED**', 'address': '**REDACTED**',
'area': '1', 'area': '1',
'bypass': '0', 'bypass': '0',
@ -453,8 +445,6 @@
'type_no': '72', 'type_no': '72',
}), }),
dict({ dict({
'_state': 'unlocked',
'_state2': 'open',
'address': '**REDACTED**', 'address': '**REDACTED**',
'area': '1', 'area': '1',
'bypass': '0', 'bypass': '0',
@ -515,7 +505,6 @@
'type_no': '72', 'type_no': '72',
}), }),
dict({ dict({
'_state': 'unavailable',
'address': '**REDACTED**', 'address': '**REDACTED**',
'area': '1', 'area': '1',
'bypass': '0', 'bypass': '0',

View File

@ -236,7 +236,7 @@
'last_changed': <ANY>, 'last_changed': <ANY>,
'last_reported': <ANY>, 'last_reported': <ANY>,
'last_updated': <ANY>, 'last_updated': <ANY>,
'state': 'unlocked', 'state': 'open',
}) })
# --- # ---
# name: test_lock[load_platforms0][lock.device9-entry] # name: test_lock[load_platforms0][lock.device9-entry]

View File

@ -47,8 +47,6 @@ async def test_lock_service_calls(
hass: HomeAssistant, hass: HomeAssistant,
get_data: YaleSmartAlarmData, get_data: YaleSmartAlarmData,
load_config_entry: tuple[MockConfigEntry, Mock], load_config_entry: tuple[MockConfigEntry, Mock],
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None: ) -> None:
"""Test the Yale Smart Alarm lock.""" """Test the Yale Smart Alarm lock."""
@ -101,8 +99,6 @@ async def test_lock_service_call_fails(
hass: HomeAssistant, hass: HomeAssistant,
get_data: YaleSmartAlarmData, get_data: YaleSmartAlarmData,
load_config_entry: tuple[MockConfigEntry, Mock], load_config_entry: tuple[MockConfigEntry, Mock],
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None: ) -> None:
"""Test the Yale Smart Alarm lock service call fails.""" """Test the Yale Smart Alarm lock service call fails."""
@ -153,8 +149,6 @@ async def test_lock_service_call_fails_with_incorrect_status(
hass: HomeAssistant, hass: HomeAssistant,
get_data: YaleSmartAlarmData, get_data: YaleSmartAlarmData,
load_config_entry: tuple[MockConfigEntry, Mock], load_config_entry: tuple[MockConfigEntry, Mock],
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None: ) -> None:
"""Test the Yale Smart Alarm lock service call fails with incorrect return state.""" """Test the Yale Smart Alarm lock service call fails with incorrect return state."""
@ -163,9 +157,7 @@ async def test_lock_service_call_fails_with_incorrect_status(
data = deepcopy(get_data.cycle) data = deepcopy(get_data.cycle)
data["data"] = data["data"].pop("device_status") data["data"] = data["data"].pop("device_status")
client.auth.get_authenticated = Mock(return_value=data)
client.auth.post_authenticated = Mock(return_value={"code": "FFF"}) client.auth.post_authenticated = Mock(return_value={"code": "FFF"})
client.lock_api = YaleDoorManAPI(client.auth)
state = hass.states.get("lock.device1") state = hass.states.get("lock.device1")
assert state.state == "locked" assert state.state == "locked"