From 02b863e968f310922e29f6f586788c8bce67f3e2 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Fri, 29 Dec 2023 14:55:41 +0100 Subject: [PATCH] Add tedee integration (#102846) * init tedee * init tests * add config flow tests * liniting * test * undo * linting * pylint * add tests * more tests * more tests * update snapshot * more tests * typing * strict typing * cleanups * cleanups, fix tests * remove extra platforms * remove codeowner * improvements * catch tedeeclientexception * allow bridge selection in CF * allow bridge selection in CF * allow bridge selection in CF * allow bridge selection in CF * abort earlier * auto-select bridge * remove cloud token, optionsflow to remove size * remove options flow leftovers * improve coverage * defer coordinator setting to after first update * define coordinator * some improvements * remove diagnostics, webhook * remove reauth flow, freeze data classes * fix lock test * Update homeassistant/components/tedee/entity.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/__init__.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/entity.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/entity.py Co-authored-by: Joost Lekkerkerker * requested changes * requested changes * Update lock.py Co-authored-by: Joost Lekkerkerker * Update entity.py Co-authored-by: Joost Lekkerkerker * Update lock.py Co-authored-by: Joost Lekkerkerker * Update config_flow.py Co-authored-by: Joost Lekkerkerker * Update config_flow.py Co-authored-by: Joost Lekkerkerker * Update config_flow.py Co-authored-by: Joost Lekkerkerker * requested changes * Update tests/components/tedee/test_lock.py Co-authored-by: Joost Lekkerkerker * Update tests/components/tedee/test_lock.py Co-authored-by: Joost Lekkerkerker * Update tests/components/tedee/test_lock.py Co-authored-by: Joost Lekkerkerker * Update tests/components/tedee/test_lock.py Co-authored-by: Joost Lekkerkerker * Update tests/components/tedee/conftest.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/coordinator.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/lock.py Co-authored-by: Joost Lekkerkerker * Update tests/components/tedee/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/strings.json Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/strings.json Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/lock.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/strings.json Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/lock.py Co-authored-by: Joost Lekkerkerker * requested changes * requested changes * requested changes * revert load fixture * change tests * Update test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update strings.json Co-authored-by: Joost Lekkerkerker * Update coordinator.py Co-authored-by: Joost Lekkerkerker * remove warning * move stuff out of try * add docstring * tedee lowercase, time.time * back to some uppercase, time.time * awaitable --------- Co-authored-by: Joost Lekkerkerker --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/tedee/__init__.py | 40 ++++ homeassistant/components/tedee/config_flow.py | 53 +++++ homeassistant/components/tedee/const.py | 9 + homeassistant/components/tedee/coordinator.py | 85 +++++++ homeassistant/components/tedee/entity.py | 40 ++++ homeassistant/components/tedee/lock.py | 119 ++++++++++ homeassistant/components/tedee/manifest.json | 10 + homeassistant/components/tedee/strings.json | 25 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/tedee/__init__.py | 1 + tests/components/tedee/conftest.py | 81 +++++++ tests/components/tedee/fixtures/locks.json | 26 +++ .../components/tedee/snapshots/test_lock.ambr | 145 ++++++++++++ tests/components/tedee/test_config_flow.py | 102 +++++++++ tests/components/tedee/test_init.py | 48 ++++ tests/components/tedee/test_lock.py | 209 ++++++++++++++++++ 22 files changed, 1019 insertions(+) create mode 100644 homeassistant/components/tedee/__init__.py create mode 100644 homeassistant/components/tedee/config_flow.py create mode 100644 homeassistant/components/tedee/const.py create mode 100644 homeassistant/components/tedee/coordinator.py create mode 100644 homeassistant/components/tedee/entity.py create mode 100644 homeassistant/components/tedee/lock.py create mode 100644 homeassistant/components/tedee/manifest.json create mode 100644 homeassistant/components/tedee/strings.json create mode 100644 tests/components/tedee/__init__.py create mode 100644 tests/components/tedee/conftest.py create mode 100644 tests/components/tedee/fixtures/locks.json create mode 100644 tests/components/tedee/snapshots/test_lock.ambr create mode 100644 tests/components/tedee/test_config_flow.py create mode 100644 tests/components/tedee/test_init.py create mode 100644 tests/components/tedee/test_lock.py diff --git a/.strict-typing b/.strict-typing index aa9c801fbf6..b85a9df857a 100644 --- a/.strict-typing +++ b/.strict-typing @@ -344,6 +344,7 @@ homeassistant.components.tailwind.* homeassistant.components.tami4.* homeassistant.components.tautulli.* homeassistant.components.tcp.* +homeassistant.components.tedee.* homeassistant.components.text.* homeassistant.components.threshold.* homeassistant.components.tibber.* diff --git a/CODEOWNERS b/CODEOWNERS index 12477a683a3..724f08bfd5e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1316,6 +1316,8 @@ build.json @home-assistant/supervisor /tests/components/tasmota/ @emontnemery /homeassistant/components/tautulli/ @ludeeus @tkdrob /tests/components/tautulli/ @ludeeus @tkdrob +/homeassistant/components/tedee/ @patrickhilker @zweckj +/tests/components/tedee/ @patrickhilker @zweckj /homeassistant/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike /homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core diff --git a/homeassistant/components/tedee/__init__.py b/homeassistant/components/tedee/__init__.py new file mode 100644 index 00000000000..2ba6131d00c --- /dev/null +++ b/homeassistant/components/tedee/__init__.py @@ -0,0 +1,40 @@ +"""Init the tedee component.""" +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import TedeeApiCoordinator + +PLATFORMS = [ + Platform.LOCK, +] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Integration setup.""" + + coordinator = TedeeApiCoordinator(hass) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/tedee/config_flow.py b/homeassistant/components/tedee/config_flow.py new file mode 100644 index 00000000000..e31bcd91693 --- /dev/null +++ b/homeassistant/components/tedee/config_flow.py @@ -0,0 +1,53 @@ +"""Config flow for Tedee integration.""" +from typing import Any + +from pytedee_async import ( + TedeeAuthException, + TedeeClient, + TedeeClientException, + TedeeLocalAuthException, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN, NAME + + +class TedeeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Tedee.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + host = user_input[CONF_HOST] + local_access_token = user_input[CONF_LOCAL_ACCESS_TOKEN] + tedee_client = TedeeClient(local_token=local_access_token, local_ip=host) + try: + local_bridge = await tedee_client.get_local_bridge() + except (TedeeAuthException, TedeeLocalAuthException): + errors[CONF_LOCAL_ACCESS_TOKEN] = "invalid_api_key" + except TedeeClientException: + errors[CONF_HOST] = "invalid_host" + + else: + await self.async_set_unique_id(local_bridge.serial) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=NAME, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_LOCAL_ACCESS_TOKEN): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/tedee/const.py b/homeassistant/components/tedee/const.py new file mode 100644 index 00000000000..bac5bfaec44 --- /dev/null +++ b/homeassistant/components/tedee/const.py @@ -0,0 +1,9 @@ +"""Constants for the Tedee integration.""" +from datetime import timedelta + +DOMAIN = "tedee" +NAME = "Tedee" + +SCAN_INTERVAL = timedelta(seconds=10) + +CONF_LOCAL_ACCESS_TOKEN = "local_access_token" diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py new file mode 100644 index 00000000000..13e26541557 --- /dev/null +++ b/homeassistant/components/tedee/coordinator.py @@ -0,0 +1,85 @@ +"""Coordinator for Tedee locks.""" +from collections.abc import Awaitable, Callable +from datetime import timedelta +import logging +import time + +from pytedee_async import ( + TedeeClient, + TedeeClientException, + TedeeDataUpdateException, + TedeeLocalAuthException, + TedeeLock, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN + +SCAN_INTERVAL = timedelta(seconds=20) +GET_LOCKS_INTERVAL_SECONDS = 3600 + +_LOGGER = logging.getLogger(__name__) + + +class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): + """Class to handle fetching data from the tedee API centrally.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + self.tedee_client = TedeeClient( + local_token=self.config_entry.data[CONF_LOCAL_ACCESS_TOKEN], + local_ip=self.config_entry.data[CONF_HOST], + ) + + self._next_get_locks = time.time() + + async def _async_update_data(self) -> dict[int, TedeeLock]: + """Fetch data from API endpoint.""" + + _LOGGER.debug("Update coordinator: Getting locks from API") + # once every hours get all lock details, otherwise use the sync endpoint + if self._next_get_locks <= time.time(): + _LOGGER.debug("Updating through /my/lock endpoint") + await self._async_update_locks(self.tedee_client.get_locks) + self._next_get_locks = time.time() + GET_LOCKS_INTERVAL_SECONDS + else: + _LOGGER.debug("Updating through /sync endpoint") + await self._async_update_locks(self.tedee_client.sync) + + _LOGGER.debug( + "available_locks: %s", + ", ".join(map(str, self.tedee_client.locks_dict.keys())), + ) + + return self.tedee_client.locks_dict + + async def _async_update_locks( + self, update_fn: Callable[[], Awaitable[None]] + ) -> None: + """Update locks based on update function.""" + try: + await update_fn() + except TedeeLocalAuthException as ex: + raise ConfigEntryError( + "Authentication failed. Local access token is invalid" + ) from ex + + except TedeeDataUpdateException as ex: + _LOGGER.debug("Error while updating data: %s", str(ex)) + raise UpdateFailed("Error while updating data: %s" % str(ex)) from ex + except (TedeeClientException, TimeoutError) as ex: + raise UpdateFailed("Querying API failed. Error: %s" % str(ex)) from ex diff --git a/homeassistant/components/tedee/entity.py b/homeassistant/components/tedee/entity.py new file mode 100644 index 00000000000..3e0ac5468e9 --- /dev/null +++ b/homeassistant/components/tedee/entity.py @@ -0,0 +1,40 @@ +"""Bases for Tedee entities.""" + +from pytedee_async.lock import TedeeLock + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TedeeApiCoordinator + + +class TedeeEntity(CoordinatorEntity[TedeeApiCoordinator]): + """Base class for Tedee entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + lock: TedeeLock, + coordinator: TedeeApiCoordinator, + key: str, + ) -> None: + """Initialize Tedee entity.""" + super().__init__(coordinator) + self._lock = lock + self._attr_unique_id = f"{lock.lock_id}-{key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(lock.lock_id))}, + name=lock.lock_name, + manufacturer="tedee", + model=lock.lock_type, + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._lock = self.coordinator.data[self._lock.lock_id] + super()._handle_coordinator_update() diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py new file mode 100644 index 00000000000..751dfb446b7 --- /dev/null +++ b/homeassistant/components/tedee/lock.py @@ -0,0 +1,119 @@ +"""Tedee lock entities.""" +from typing import Any + +from pytedee_async import TedeeClientException, TedeeLock, TedeeLockState + +from homeassistant.components.lock import LockEntity, LockEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TedeeApiCoordinator +from .entity import TedeeEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Tedee lock entity.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + entities: list[TedeeLockEntity] = [] + for lock in coordinator.data.values(): + if lock.is_enabled_pullspring: + entities.append(TedeeLockWithLatchEntity(lock, coordinator)) + else: + entities.append(TedeeLockEntity(lock, coordinator)) + + async_add_entities(entities) + + +class TedeeLockEntity(TedeeEntity, LockEntity): + """A tedee lock that doesn't have pullspring enabled.""" + + _attr_name = None + + def __init__( + self, + lock: TedeeLock, + coordinator: TedeeApiCoordinator, + ) -> None: + """Initialize the lock.""" + super().__init__(lock, coordinator, "lock") + + @property + def is_locked(self) -> bool: + """Return true if lock is locked.""" + return self._lock.state == TedeeLockState.LOCKED + + @property + def is_unlocking(self) -> bool: + """Return true if lock is unlocking.""" + return self._lock.state == TedeeLockState.UNLOCKING + + @property + def is_locking(self) -> bool: + """Return true if lock is locking.""" + return self._lock.state == TedeeLockState.LOCKING + + @property + def is_jammed(self) -> bool: + """Return true if lock is jammed.""" + return self._lock.is_state_jammed + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._lock.is_connected + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the door.""" + try: + self._lock.state = TedeeLockState.UNLOCKING + self.async_write_ha_state() + + await self.coordinator.tedee_client.unlock(self._lock.lock_id) + await self.coordinator.async_request_refresh() + except (TedeeClientException, Exception) as ex: + raise HomeAssistantError( + "Failed to unlock the door. Lock %s" % self._lock.lock_id + ) from ex + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the door.""" + try: + self._lock.state = TedeeLockState.LOCKING + self.async_write_ha_state() + + await self.coordinator.tedee_client.lock(self._lock.lock_id) + await self.coordinator.async_request_refresh() + except (TedeeClientException, Exception) as ex: + raise HomeAssistantError( + "Failed to lock the door. Lock %s" % self._lock.lock_id + ) from ex + + +class TedeeLockWithLatchEntity(TedeeLockEntity): + """A tedee lock but has pullspring enabled, so it additional features.""" + + @property + def supported_features(self) -> LockEntityFeature: + """Flag supported features.""" + return LockEntityFeature.OPEN + + async def async_open(self, **kwargs: Any) -> None: + """Open the door with pullspring.""" + try: + self._lock.state = TedeeLockState.UNLOCKING + self.async_write_ha_state() + + await self.coordinator.tedee_client.open(self._lock.lock_id) + await self.coordinator.async_request_refresh() + except (TedeeClientException, Exception) as ex: + raise HomeAssistantError( + "Failed to unlatch the door. Lock %s" % self._lock.lock_id + ) from ex diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json new file mode 100644 index 00000000000..4055130e5e7 --- /dev/null +++ b/homeassistant/components/tedee/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "tedee", + "name": "Tedee", + "codeowners": ["@patrickhilker", "@zweckj"], + "config_flow": true, + "dependencies": ["http"], + "documentation": "https://www.home-assistant.io/integrations/tedee", + "iot_class": "local_push", + "requirements": ["pytedee-async==0.2.1"] +} diff --git a/homeassistant/components/tedee/strings.json b/homeassistant/components/tedee/strings.json new file mode 100644 index 00000000000..e9286d894aa --- /dev/null +++ b/homeassistant/components/tedee/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "title": "Setup your tedee locks", + "data": { + "local_access_token": "Local access token", + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The IP address of the bridge you want to connect to.", + "local_access_token": "You can find it in the tedee app under \"Bridge Settings\" -> \"Local API\"." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index cba1a88d25b..254a3ad0df3 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -500,6 +500,7 @@ FLOWS = { "tankerkoenig", "tasmota", "tautulli", + "tedee", "tellduslive", "tesla_wall_connector", "tessie", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 45bcc1788cd..c55b6aecce9 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5804,6 +5804,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "tedee": { + "name": "Tedee", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "telegram": { "name": "Telegram", "integrations": { diff --git a/mypy.ini b/mypy.ini index e19c6c6fa92..1f810bdb1ae 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3202,6 +3202,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tedee.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.text.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 3eb0bd1e913..f9b312e391f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2134,6 +2134,9 @@ pytankerkoenig==0.0.6 # homeassistant.components.tautulli pytautulli==23.1.1 +# homeassistant.components.tedee +pytedee-async==0.2.1 + # homeassistant.components.tfiac pytfiac==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea2d7072dc3..289732c846f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1631,6 +1631,9 @@ pytankerkoenig==0.0.6 # homeassistant.components.tautulli pytautulli==23.1.1 +# homeassistant.components.tedee +pytedee-async==0.2.1 + # homeassistant.components.motionmount python-MotionMount==0.3.1 diff --git a/tests/components/tedee/__init__.py b/tests/components/tedee/__init__.py new file mode 100644 index 00000000000..a72b1fbdd6a --- /dev/null +++ b/tests/components/tedee/__init__.py @@ -0,0 +1 @@ +"""Add tests for Tedee components.""" diff --git a/tests/components/tedee/conftest.py b/tests/components/tedee/conftest.py new file mode 100644 index 00000000000..21fb4047ab3 --- /dev/null +++ b/tests/components/tedee/conftest.py @@ -0,0 +1,81 @@ +"""Fixtures for Tedee integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +import json +from unittest.mock import AsyncMock, MagicMock, patch + +from pytedee_async.bridge import TedeeBridge +from pytedee_async.lock import TedeeLock +import pytest + +from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="My Tedee", + domain=DOMAIN, + data={ + CONF_LOCAL_ACCESS_TOKEN: "api_token", + CONF_HOST: "192.168.1.42", + }, + unique_id="0000-0000", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.tedee.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_tedee(request) -> Generator[MagicMock, None, None]: + """Return a mocked Tedee client.""" + with patch( + "homeassistant.components.tedee.coordinator.TedeeClient", autospec=True + ) as tedee_mock, patch( + "homeassistant.components.tedee.config_flow.TedeeClient", + new=tedee_mock, + ): + tedee = tedee_mock.return_value + + tedee.get_locks.return_value = None + tedee.sync.return_value = None + tedee.get_bridges.return_value = [ + TedeeBridge(1234, "0000-0000", "Bridge-AB1C"), + TedeeBridge(5678, "9999-9999", "Bridge-CD2E"), + ] + tedee.get_local_bridge.return_value = TedeeBridge(0, "0000-0000", "Bridge-AB1C") + + tedee.parse_webhook_message.return_value = None + + locks_json = json.loads(load_fixture("locks.json", DOMAIN)) + + lock_list = [TedeeLock(**lock) for lock in locks_json] + tedee.locks_dict = {lock.lock_id: lock for lock in lock_list} + + yield tedee + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_tedee: MagicMock +) -> MockConfigEntry: + """Set up the Tedee integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/tedee/fixtures/locks.json b/tests/components/tedee/fixtures/locks.json new file mode 100644 index 00000000000..6a8eb77d7ee --- /dev/null +++ b/tests/components/tedee/fixtures/locks.json @@ -0,0 +1,26 @@ +[ + { + "lock_name": "Lock-1A2B", + "lock_id": 12345, + "lock_type": 2, + "state": 2, + "battery_level": 70, + "is_connected": true, + "is_charging": false, + "state_change_result": 0, + "is_enabled_pullspring": 1, + "duration_pullspring": 2 + }, + { + "lock_name": "Lock-2C3D", + "lock_id": 98765, + "lock_type": 4, + "state": 2, + "battery_level": 70, + "is_connected": true, + "is_charging": false, + "state_change_result": 0, + "is_enabled_pullspring": 0, + "duration_pullspring": 0 + } +] diff --git a/tests/components/tedee/snapshots/test_lock.ambr b/tests/components/tedee/snapshots/test_lock.ambr new file mode 100644 index 00000000000..ad89e9c842d --- /dev/null +++ b/tests/components/tedee/snapshots/test_lock.ambr @@ -0,0 +1,145 @@ +# serializer version: 1 +# name: test_lock + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lock-1A2B', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.lock_1a2b', + 'last_changed': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- +# name: test_lock.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.lock_1a2b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12345-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tedee', + '12345', + ), + }), + 'is_new': False, + 'manufacturer': 'tedee', + 'model': 'Tedee PRO', + 'name': 'Lock-1A2B', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_lock_without_pullspring + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lock-2C3D', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.lock_2c3d', + 'last_changed': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- +# name: test_lock_without_pullspring.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.lock_2c3d', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '98765-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock_without_pullspring.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tedee', + '98765', + ), + }), + 'is_new': False, + 'manufacturer': 'tedee', + 'model': 'Tedee GO', + 'name': 'Lock-2C3D', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tedee/test_config_flow.py b/tests/components/tedee/test_config_flow.py new file mode 100644 index 00000000000..73132d3bd78 --- /dev/null +++ b/tests/components/tedee/test_config_flow.py @@ -0,0 +1,102 @@ +"""Test the Tedee config flow.""" +from unittest.mock import MagicMock + +from pytedee_async import TedeeClientException, TedeeLocalAuthException +import pytest + +from homeassistant import config_entries +from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +FLOW_UNIQUE_ID = "112233445566778899" +LOCAL_ACCESS_TOKEN = "api_token" + + +async def test_flow(hass: HomeAssistant, mock_tedee: MagicMock) -> None: + """Test config flow with one bridge.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.62", + CONF_LOCAL_ACCESS_TOKEN: "token", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == { + CONF_HOST: "192.168.1.62", + CONF_LOCAL_ACCESS_TOKEN: "token", + } + + +async def test_flow_already_configured( + hass: HomeAssistant, + mock_tedee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config flow aborts when already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.62", + CONF_LOCAL_ACCESS_TOKEN: "token", + }, + ) + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (TedeeClientException("boom."), {CONF_HOST: "invalid_host"}), + ( + TedeeLocalAuthException("boom."), + {CONF_LOCAL_ACCESS_TOKEN: "invalid_api_key"}, + ), + ], +) +async def test_config_flow_errors( + hass: HomeAssistant, + mock_tedee: MagicMock, + side_effect: Exception, + error: dict[str, str], +) -> None: + """Test the config flow errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + + mock_tedee.get_local_bridge.side_effect = side_effect + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.42", + CONF_LOCAL_ACCESS_TOKEN: "wrong_token", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == error + assert len(mock_tedee.get_local_bridge.mock_calls) == 1 diff --git a/tests/components/tedee/test_init.py b/tests/components/tedee/test_init.py new file mode 100644 index 00000000000..874a827e458 --- /dev/null +++ b/tests/components/tedee/test_init.py @@ -0,0 +1,48 @@ +"""Test initialization of tedee.""" +from unittest.mock import MagicMock + +from pytedee_async.exception import TedeeAuthException, TedeeClientException +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, +) -> None: + """Test loading and unloading the integration.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + "side_effect", [TedeeClientException(""), TedeeAuthException("")] +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, + side_effect: Exception, +) -> None: + """Test the LaMetric configuration entry not ready.""" + mock_tedee.get_locks.side_effect = side_effect + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_tedee.get_locks.mock_calls) == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/tedee/test_lock.py b/tests/components/tedee/test_lock.py new file mode 100644 index 00000000000..995d036fba7 --- /dev/null +++ b/tests/components/tedee/test_lock.py @@ -0,0 +1,209 @@ +"""Tests for tedee lock.""" +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from pytedee_async.exception import ( + TedeeClientException, + TedeeDataUpdateException, + TedeeLocalAuthException, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + SERVICE_LOCK, + SERVICE_OPEN, + SERVICE_UNLOCK, + STATE_LOCKING, + STATE_UNLOCKING, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import async_fire_time_changed + +pytestmark = pytest.mark.usefixtures("init_integration") + + +async def test_lock( + hass: HomeAssistant, + mock_tedee: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the tedee lock.""" + mock_tedee.lock.return_value = None + mock_tedee.unlock.return_value = None + mock_tedee.open.return_value = None + + state = hass.states.get("lock.lock_1a2b") + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot + assert entry.device_id + + device = device_registry.async_get(entry.device_id) + assert device == snapshot + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + { + ATTR_ENTITY_ID: "lock.lock_1a2b", + }, + blocking=True, + ) + + assert len(mock_tedee.lock.mock_calls) == 1 + mock_tedee.lock.assert_called_once_with(12345) + state = hass.states.get("lock.lock_1a2b") + assert state + assert state.state == STATE_LOCKING + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + { + ATTR_ENTITY_ID: "lock.lock_1a2b", + }, + blocking=True, + ) + + assert len(mock_tedee.unlock.mock_calls) == 1 + mock_tedee.unlock.assert_called_once_with(12345) + state = hass.states.get("lock.lock_1a2b") + assert state + assert state.state == STATE_UNLOCKING + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_OPEN, + { + ATTR_ENTITY_ID: "lock.lock_1a2b", + }, + blocking=True, + ) + + assert len(mock_tedee.open.mock_calls) == 1 + mock_tedee.open.assert_called_once_with(12345) + state = hass.states.get("lock.lock_1a2b") + assert state + assert state.state == STATE_UNLOCKING + + +async def test_lock_without_pullspring( + hass: HomeAssistant, + mock_tedee: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the tedee lock without pullspring.""" + mock_tedee.lock.return_value = None + mock_tedee.unlock.return_value = None + mock_tedee.open.return_value = None + + state = hass.states.get("lock.lock_2c3d") + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot + + assert entry.device_id + device = device_registry.async_get(entry.device_id) + assert device + assert device == snapshot + + with pytest.raises( + HomeAssistantError, + match="Entity lock.lock_2c3d does not support this service.", + ): + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_OPEN, + { + ATTR_ENTITY_ID: "lock.lock_2c3d", + }, + blocking=True, + ) + + assert len(mock_tedee.open.mock_calls) == 0 + + +async def test_lock_errors( + hass: HomeAssistant, + mock_tedee: MagicMock, +) -> None: + """Test event errors.""" + mock_tedee.lock.side_effect = TedeeClientException("Boom") + with pytest.raises(HomeAssistantError, match="Failed to lock the door. Lock 12345"): + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + { + ATTR_ENTITY_ID: "lock.lock_1a2b", + }, + blocking=True, + ) + + mock_tedee.unlock.side_effect = TedeeClientException("Boom") + with pytest.raises( + HomeAssistantError, match="Failed to unlock the door. Lock 12345" + ): + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + { + ATTR_ENTITY_ID: "lock.lock_1a2b", + }, + blocking=True, + ) + + mock_tedee.open.side_effect = TedeeClientException("Boom") + with pytest.raises( + HomeAssistantError, match="Failed to unlatch the door. Lock 12345" + ): + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_OPEN, + { + ATTR_ENTITY_ID: "lock.lock_1a2b", + }, + blocking=True, + ) + + +@pytest.mark.parametrize( + "side_effect", + [ + TedeeClientException("Boom"), + TedeeLocalAuthException("Boom"), + TimeoutError, + TedeeDataUpdateException("Boom"), + ], +) +async def test_update_failed( + hass: HomeAssistant, + mock_tedee: MagicMock, + freezer: FrozenDateTimeFactory, + side_effect: Exception, +) -> None: + """Test update failed.""" + mock_tedee.sync.side_effect = side_effect + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("lock.lock_1a2b") + assert state is not None + assert state.state == STATE_UNAVAILABLE