Skip to content

Commit

Permalink
Re-introduce webhook to tedee integration (#110247)
Browse files Browse the repository at this point in the history
* bring webhook over to new branch

* change log levels

* Update homeassistant/components/tedee/coordinator.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* fix minor version

* ruff

* mock config entry version

* fix

* ruff

* add cleanup during webhook registration

* feedback

* ruff

* Update __init__.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Update homeassistant/components/tedee/__init__.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* add downgrade test

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
  • Loading branch information
3 people committed May 14, 2024
1 parent b684801 commit d0e99b6
Show file tree
Hide file tree
Showing 8 changed files with 393 additions and 35 deletions.
111 changes: 105 additions & 6 deletions homeassistant/components/tedee/__init__.py
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -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
Expand All @@ -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
11 changes: 9 additions & 2 deletions homeassistant/components/tedee/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
)
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
Expand All @@ -25,6 +26,9 @@
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(
Expand Down Expand Up @@ -65,7 +69,10 @@ async def async_step_user(
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",
Expand Down
24 changes: 23 additions & 1 deletion homeassistant/components/tedee/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
from datetime import timedelta
import logging
import time
from typing import Any

from pytedee_async import (
TedeeClient,
TedeeClientException,
TedeeDataUpdateException,
TedeeLocalAuthException,
TedeeLock,
TedeeWebhookException,
)
from pytedee_async.bridge import TedeeBridge

Expand All @@ -24,7 +26,7 @@

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__)
Expand Down Expand Up @@ -54,6 +56,7 @@ def __init__(self, hass: HomeAssistant) -> None:
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:
Expand Down Expand Up @@ -104,6 +107,25 @@ async def _async_update(self, update_fn: Callable[[], Awaitable[None]]) -> None:
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:
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/tedee/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
10 changes: 8 additions & 2 deletions tests/components/tedee/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@
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:
Expand All @@ -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,
)


Expand Down Expand Up @@ -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))

Expand All @@ -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()

Expand Down
45 changes: 26 additions & 19 deletions tests/components/tedee/test_config_flow.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -11,10 +11,12 @@

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"
Expand All @@ -23,25 +25,30 @@

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

result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
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"],
{
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(
Expand Down

0 comments on commit d0e99b6

Please sign in to comment.