diff --git a/homeassistant/components/tedee/__init__.py b/homeassistant/components/tedee/__init__.py index 9468008ae8a..9a4199962ff 100644 --- a/homeassistant/components/tedee/__init__.py +++ b/homeassistant/components/tedee/__init__.py @@ -1,13 +1,28 @@ """Init the tedee component.""" +from collections.abc import Awaitable, Callable +from http import HTTPStatus import logging +from typing import Any +from aiohttp.hdrs import METH_POST +from aiohttp.web import Request, Response +from pytedee_async.exception import TedeeDataUpdateException, TedeeWebhookException + +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.webhook import ( + async_generate_id as webhook_generate_id, + async_generate_path as webhook_generate_path, + async_register as webhook_register, + async_unregister as webhook_unregister, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.network import get_url -from .const import DOMAIN +from .const import DOMAIN, NAME from .coordinator import TedeeApiCoordinator PLATFORMS = [ @@ -38,6 +53,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + async def unregister_webhook(_: Any) -> None: + await coordinator.async_unregister_webhook() + webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + + async def register_webhook() -> None: + instance_url = get_url(hass, allow_ip=True, allow_external=False) + # first make sure we don't have leftover callbacks to the same instance + try: + await coordinator.tedee_client.cleanup_webhooks_by_host(instance_url) + except (TedeeDataUpdateException, TedeeWebhookException) as ex: + _LOGGER.warning("Failed to cleanup Tedee webhooks by host: %s", ex) + webhook_url = ( + f"{instance_url}{webhook_generate_path(entry.data[CONF_WEBHOOK_ID])}" + ) + webhook_name = "Tedee" + if entry.title != NAME: + webhook_name = f"{NAME} {entry.title}" + + webhook_register( + hass, + DOMAIN, + webhook_name, + entry.data[CONF_WEBHOOK_ID], + get_webhook_handler(coordinator), + allowed_methods=[METH_POST], + ) + _LOGGER.debug("Registered Tedee webhook at hass: %s", webhook_url) + + try: + await coordinator.async_register_webhook(webhook_url) + except TedeeWebhookException: + _LOGGER.exception("Failed to register Tedee webhook from bridge") + else: + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) + ) + + entry.async_create_background_task( + hass, register_webhook(), "tedee_register_webhook" + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -46,9 +101,53 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +def get_webhook_handler( + coordinator: TedeeApiCoordinator, +) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]: + """Return webhook handler.""" + + async def async_webhook_handler( + hass: HomeAssistant, webhook_id: str, request: Request + ) -> Response | None: + # Handle http post calls to the path. + if not request.body_exists: + return HomeAssistantView.json( + result="No Body", status_code=HTTPStatus.BAD_REQUEST + ) + + body = await request.json() + try: + coordinator.webhook_received(body) + except TedeeWebhookException as ex: + return HomeAssistantView.json( + result=str(ex), status_code=HTTPStatus.BAD_REQUEST + ) + + return HomeAssistantView.json(result="OK", status_code=HTTPStatus.OK) + + return async_webhook_handler + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + version = config_entry.version + minor_version = config_entry.minor_version + + if version == 1 and minor_version == 1: + _LOGGER.debug( + "Migrating Tedee config entry from version %s.%s", version, minor_version + ) + data = {**config_entry.data, CONF_WEBHOOK_ID: webhook_generate_id()} + hass.config_entries.async_update_entry(config_entry, data=data, minor_version=2) + _LOGGER.debug("Migration to version 1.2 successful") + return True diff --git a/homeassistant/components/tedee/config_flow.py b/homeassistant/components/tedee/config_flow.py index 8465b332539..dacaea57176 100644 --- a/homeassistant/components/tedee/config_flow.py +++ b/homeassistant/components/tedee/config_flow.py @@ -13,8 +13,9 @@ from pytedee_async import ( ) import voluptuous as vol +from homeassistant.components.webhook import async_generate_id as webhook_generate_id from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN, NAME @@ -25,6 +26,9 @@ _LOGGER = logging.getLogger(__name__) class TedeeConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Tedee.""" + VERSION = 1 + MINOR_VERSION = 2 + reauth_entry: ConfigEntry | None = None async def async_step_user( @@ -65,7 +69,10 @@ class TedeeConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reauth_successful") 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_create_entry( + title=NAME, + data={**user_input, CONF_WEBHOOK_ID: webhook_generate_id()}, + ) return self.async_show_form( step_id="user", diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index 22489af6b40..51dc6a57d90 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -4,6 +4,7 @@ from collections.abc import Awaitable, Callable from datetime import timedelta import logging import time +from typing import Any from pytedee_async import ( TedeeClient, @@ -11,6 +12,7 @@ from pytedee_async import ( TedeeDataUpdateException, TedeeLocalAuthException, TedeeLock, + TedeeWebhookException, ) from pytedee_async.bridge import TedeeBridge @@ -24,7 +26,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN -SCAN_INTERVAL = timedelta(seconds=20) +SCAN_INTERVAL = timedelta(seconds=30) GET_LOCKS_INTERVAL_SECONDS = 3600 _LOGGER = logging.getLogger(__name__) @@ -54,6 +56,7 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): self._next_get_locks = time.time() self._locks_last_update: set[int] = set() self.new_lock_callbacks: list[Callable[[int], None]] = [] + self.tedee_webhook_id: int | None = None @property def bridge(self) -> TedeeBridge: @@ -104,6 +107,25 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): except (TedeeClientException, TimeoutError) as ex: raise UpdateFailed(f"Querying API failed. Error: {ex!s}") from ex + def webhook_received(self, message: dict[str, Any]) -> None: + """Handle webhook message.""" + self.tedee_client.parse_webhook_message(message) + self.async_set_updated_data(self.tedee_client.locks_dict) + + async def async_register_webhook(self, webhook_url: str) -> None: + """Register the webhook at the Tedee bridge.""" + self.tedee_webhook_id = await self.tedee_client.register_webhook(webhook_url) + + async def async_unregister_webhook(self) -> None: + """Unregister the webhook at the Tedee bridge.""" + if self.tedee_webhook_id is not None: + try: + await self.tedee_client.delete_webhook(self.tedee_webhook_id) + except TedeeWebhookException: + _LOGGER.exception("Failed to unregister Tedee webhook from bridge") + else: + _LOGGER.debug("Unregistered Tedee webhook") + def _async_add_remove_locks(self) -> None: """Add new locks, remove non-existing locks.""" if not self._locks_last_update: diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index db3a88f3113..6fea68985f7 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -3,7 +3,7 @@ "name": "Tedee", "codeowners": ["@patrickhilker", "@zweckj"], "config_flow": true, - "dependencies": ["http"], + "dependencies": ["http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/tedee", "iot_class": "local_push", "loggers": ["pytedee_async"], diff --git a/tests/components/tedee/conftest.py b/tests/components/tedee/conftest.py index 9f0730992d2..14499935de2 100644 --- a/tests/components/tedee/conftest.py +++ b/tests/components/tedee/conftest.py @@ -11,11 +11,13 @@ 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.const import CONF_HOST, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture +WEBHOOK_ID = "bq33efxmdi3vxy55q2wbnudbra7iv8mjrq9x0gea33g4zqtd87093pwveg8xcb33" + @pytest.fixture def mock_config_entry() -> MockConfigEntry: @@ -26,8 +28,11 @@ def mock_config_entry() -> MockConfigEntry: data={ CONF_LOCAL_ACCESS_TOKEN: "api_token", CONF_HOST: "192.168.1.42", + CONF_WEBHOOK_ID: WEBHOOK_ID, }, unique_id="0000-0000", + version=1, + minor_version=2, ) @@ -63,6 +68,8 @@ def mock_tedee(request) -> Generator[MagicMock, None, None]: tedee.get_local_bridge.return_value = TedeeBridge(0, "0000-0000", "Bridge-AB1C") tedee.parse_webhook_message.return_value = None + tedee.register_webhook.return_value = 1 + tedee.delete_webhooks.return_value = None locks_json = json.loads(load_fixture("locks.json", DOMAIN)) @@ -78,7 +85,6 @@ async def init_integration( ) -> 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() diff --git a/tests/components/tedee/test_config_flow.py b/tests/components/tedee/test_config_flow.py index 1da1e392bf3..588e63f693b 100644 --- a/tests/components/tedee/test_config_flow.py +++ b/tests/components/tedee/test_config_flow.py @@ -1,6 +1,6 @@ """Test the Tedee config flow.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from pytedee_async import ( TedeeClientException, @@ -11,10 +11,12 @@ import pytest from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import WEBHOOK_ID + from tests.common import MockConfigEntry FLOW_UNIQUE_ID = "112233445566778899" @@ -23,25 +25,30 @@ 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": SOURCE_USER} - ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM + with patch( + "homeassistant.components.tedee.config_flow.webhook_generate_id", + return_value=WEBHOOK_ID, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { + 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", - }, - ) - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["data"] == { - CONF_HOST: "192.168.1.62", - CONF_LOCAL_ACCESS_TOKEN: "token", - } + CONF_WEBHOOK_ID: WEBHOOK_ID, + } async def test_flow_already_configured( diff --git a/tests/components/tedee/test_init.py b/tests/components/tedee/test_init.py index 9388aaf008c..d4ac1c9d290 100644 --- a/tests/components/tedee/test_init.py +++ b/tests/components/tedee/test_init.py @@ -1,16 +1,29 @@ """Test initialization of tedee.""" -from unittest.mock import MagicMock +from http import HTTPStatus +from typing import Any +from unittest.mock import MagicMock, patch +from urllib.parse import urlparse -from pytedee_async.exception import TedeeAuthException, TedeeClientException +from pytedee_async.exception import ( + TedeeAuthException, + TedeeClientException, + TedeeWebhookException, +) import pytest from syrupy import SnapshotAssertion +from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN +from homeassistant.components.webhook import async_generate_url from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from .conftest import WEBHOOK_ID + from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator async def test_load_unload_config_entry( @@ -51,6 +64,80 @@ async def test_config_entry_not_ready( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_cleanup_on_shutdown( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, +) -> None: + """Test the webhook is cleaned up on shutdown.""" + 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 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + mock_tedee.delete_webhook.assert_called_once() + + +async def test_webhook_cleanup_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the webhook is cleaned up on shutdown.""" + 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 + + mock_tedee.delete_webhook.side_effect = TedeeWebhookException("") + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + mock_tedee.delete_webhook.assert_called_once() + assert "Failed to unregister Tedee webhook from bridge" in caplog.text + + +async def test_webhook_registration_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the webhook is cleaned up on shutdown.""" + mock_tedee.register_webhook.side_effect = TedeeWebhookException("") + 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 + + mock_tedee.register_webhook.assert_called_once() + assert "Failed to register Tedee webhook from bridge" in caplog.text + + +async def test_webhook_registration_cleanup_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the errors during webhook cleanup during registration.""" + mock_tedee.cleanup_webhooks_by_host.side_effect = TedeeWebhookException("") + 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 + + mock_tedee.cleanup_webhooks_by_host.assert_called_once() + assert "Failed to cleanup Tedee webhooks by host:" in caplog.text + + async def test_bridge_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -68,3 +155,97 @@ async def test_bridge_device( ) assert device assert device == snapshot + + +@pytest.mark.parametrize( + ( + "body", + "expected_code", + "side_effect", + ), + [ + ( + {"hello": "world"}, + HTTPStatus.OK, + None, + ), # Success + ( + None, + HTTPStatus.BAD_REQUEST, + None, + ), # Missing data + ( + {}, + HTTPStatus.BAD_REQUEST, + TedeeWebhookException, + ), # Error + ], +) +async def test_webhook_post( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, + hass_client_no_auth: ClientSessionGenerator, + body: dict[str, Any], + expected_code: HTTPStatus, + side_effect: Exception, +) -> None: + """Test webhook callback.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + client = await hass_client_no_auth() + webhook_url = async_generate_url(hass, WEBHOOK_ID) + mock_tedee.parse_webhook_message.side_effect = side_effect + resp = await client.post(urlparse(webhook_url).path, json=body) + + # Wait for remaining tasks to complete. + await hass.async_block_till_done() + + assert resp.status == expected_code + + +async def test_config_flow_entry_migrate_2_1(hass: HomeAssistant) -> None: + """Test that config entry fails setup if the version is from the future.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + minor_version=1, + ) + entry.add_to_hass(hass) + + assert not await hass.config_entries.async_setup(entry.entry_id) + + +async def test_migration( + hass: HomeAssistant, + mock_tedee: MagicMock, +) -> None: + """Test migration of the config entry.""" + + mock_config_entry = MockConfigEntry( + title="My Tedee", + domain=DOMAIN, + data={ + CONF_LOCAL_ACCESS_TOKEN: "api_token", + CONF_HOST: "192.168.1.42", + }, + version=1, + minor_version=1, + unique_id="0000-0000", + ) + + with patch( + "homeassistant.components.tedee.webhook_generate_id", + return_value=WEBHOOK_ID, + ): + 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.version == 1 + assert mock_config_entry.minor_version == 2 + assert mock_config_entry.data[CONF_WEBHOOK_ID] == WEBHOOK_ID + assert mock_config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/tedee/test_lock.py b/tests/components/tedee/test_lock.py index f108c4f09f0..ffc4a8c30d6 100644 --- a/tests/components/tedee/test_lock.py +++ b/tests/components/tedee/test_lock.py @@ -2,9 +2,10 @@ from datetime import timedelta from unittest.mock import MagicMock +from urllib.parse import urlparse from freezegun.api import FrozenDateTimeFactory -from pytedee_async import TedeeLock +from pytedee_async import TedeeLock, TedeeLockState from pytedee_async.exception import ( TedeeClientException, TedeeDataUpdateException, @@ -18,15 +19,21 @@ from homeassistant.components.lock import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, + STATE_LOCKED, STATE_LOCKING, + STATE_UNLOCKED, STATE_UNLOCKING, ) +from homeassistant.components.webhook import async_generate_url 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 .conftest import WEBHOOK_ID + from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import ClientSessionGenerator pytestmark = pytest.mark.usefixtures("init_integration") @@ -267,3 +274,32 @@ async def test_new_lock( assert state state = hass.states.get("lock.lock_6g7h") assert state + + +async def test_webhook_update( + hass: HomeAssistant, + mock_tedee: MagicMock, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test updated data set through webhook.""" + + state = hass.states.get("lock.lock_1a2b") + assert state + assert state.state == STATE_UNLOCKED + + webhook_data = {"dummystate": 6} + mock_tedee.locks_dict[ + 12345 + ].state = TedeeLockState.LOCKED # is updated in the lib, so mock and assert in L296 + client = await hass_client_no_auth() + webhook_url = async_generate_url(hass, WEBHOOK_ID) + + await client.post( + urlparse(webhook_url).path, + json=webhook_data, + ) + mock_tedee.parse_webhook_message.assert_called_once_with(webhook_data) + + state = hass.states.get("lock.lock_1a2b") + assert state + assert state.state == STATE_LOCKED