Re-introduce webhook to tedee integration (#110247)
* 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>
This commit is contained in:
parent
b684801cae
commit
d0e99b62da
8 changed files with 390 additions and 32 deletions
|
@ -1,13 +1,28 @@
|
||||||
"""Init the tedee component."""
|
"""Init the tedee component."""
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from http import HTTPStatus
|
||||||
import logging
|
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.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.core import HomeAssistant
|
||||||
from homeassistant.helpers import device_registry as dr
|
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
|
from .coordinator import TedeeApiCoordinator
|
||||||
|
|
||||||
PLATFORMS = [
|
PLATFORMS = [
|
||||||
|
@ -38,6 +53,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
|
||||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
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)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
return True
|
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:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
|
|
||||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||||
|
|
||||||
if unload_ok:
|
|
||||||
hass.data[DOMAIN].pop(entry.entry_id)
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
|
||||||
return unload_ok
|
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
|
||||||
|
|
|
@ -13,8 +13,9 @@ from pytedee_async import (
|
||||||
)
|
)
|
||||||
import voluptuous as vol
|
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.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 homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN, NAME
|
from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN, NAME
|
||||||
|
@ -25,6 +26,9 @@ _LOGGER = logging.getLogger(__name__)
|
||||||
class TedeeConfigFlow(ConfigFlow, domain=DOMAIN):
|
class TedeeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow for Tedee."""
|
"""Handle a config flow for Tedee."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
MINOR_VERSION = 2
|
||||||
|
|
||||||
reauth_entry: ConfigEntry | None = None
|
reauth_entry: ConfigEntry | None = None
|
||||||
|
|
||||||
async def async_step_user(
|
async def async_step_user(
|
||||||
|
@ -65,7 +69,10 @@ class TedeeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
return self.async_abort(reason="reauth_successful")
|
return self.async_abort(reason="reauth_successful")
|
||||||
await self.async_set_unique_id(local_bridge.serial)
|
await self.async_set_unique_id(local_bridge.serial)
|
||||||
self._abort_if_unique_id_configured()
|
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(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
|
|
|
@ -4,6 +4,7 @@ from collections.abc import Awaitable, Callable
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from pytedee_async import (
|
from pytedee_async import (
|
||||||
TedeeClient,
|
TedeeClient,
|
||||||
|
@ -11,6 +12,7 @@ from pytedee_async import (
|
||||||
TedeeDataUpdateException,
|
TedeeDataUpdateException,
|
||||||
TedeeLocalAuthException,
|
TedeeLocalAuthException,
|
||||||
TedeeLock,
|
TedeeLock,
|
||||||
|
TedeeWebhookException,
|
||||||
)
|
)
|
||||||
from pytedee_async.bridge import TedeeBridge
|
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
|
from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN
|
||||||
|
|
||||||
SCAN_INTERVAL = timedelta(seconds=20)
|
SCAN_INTERVAL = timedelta(seconds=30)
|
||||||
GET_LOCKS_INTERVAL_SECONDS = 3600
|
GET_LOCKS_INTERVAL_SECONDS = 3600
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
@ -54,6 +56,7 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]):
|
||||||
self._next_get_locks = time.time()
|
self._next_get_locks = time.time()
|
||||||
self._locks_last_update: set[int] = set()
|
self._locks_last_update: set[int] = set()
|
||||||
self.new_lock_callbacks: list[Callable[[int], None]] = []
|
self.new_lock_callbacks: list[Callable[[int], None]] = []
|
||||||
|
self.tedee_webhook_id: int | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bridge(self) -> TedeeBridge:
|
def bridge(self) -> TedeeBridge:
|
||||||
|
@ -104,6 +107,25 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]):
|
||||||
except (TedeeClientException, TimeoutError) as ex:
|
except (TedeeClientException, TimeoutError) as ex:
|
||||||
raise UpdateFailed(f"Querying API failed. Error: {ex!s}") from 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:
|
def _async_add_remove_locks(self) -> None:
|
||||||
"""Add new locks, remove non-existing locks."""
|
"""Add new locks, remove non-existing locks."""
|
||||||
if not self._locks_last_update:
|
if not self._locks_last_update:
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
"name": "Tedee",
|
"name": "Tedee",
|
||||||
"codeowners": ["@patrickhilker", "@zweckj"],
|
"codeowners": ["@patrickhilker", "@zweckj"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": ["http"],
|
"dependencies": ["http", "webhook"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/tedee",
|
"documentation": "https://www.home-assistant.io/integrations/tedee",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["pytedee_async"],
|
"loggers": ["pytedee_async"],
|
||||||
|
|
|
@ -11,11 +11,13 @@ from pytedee_async.lock import TedeeLock
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN
|
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 homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from tests.common import MockConfigEntry, load_fixture
|
from tests.common import MockConfigEntry, load_fixture
|
||||||
|
|
||||||
|
WEBHOOK_ID = "bq33efxmdi3vxy55q2wbnudbra7iv8mjrq9x0gea33g4zqtd87093pwveg8xcb33"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_config_entry() -> MockConfigEntry:
|
def mock_config_entry() -> MockConfigEntry:
|
||||||
|
@ -26,8 +28,11 @@ def mock_config_entry() -> MockConfigEntry:
|
||||||
data={
|
data={
|
||||||
CONF_LOCAL_ACCESS_TOKEN: "api_token",
|
CONF_LOCAL_ACCESS_TOKEN: "api_token",
|
||||||
CONF_HOST: "192.168.1.42",
|
CONF_HOST: "192.168.1.42",
|
||||||
|
CONF_WEBHOOK_ID: WEBHOOK_ID,
|
||||||
},
|
},
|
||||||
unique_id="0000-0000",
|
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.get_local_bridge.return_value = TedeeBridge(0, "0000-0000", "Bridge-AB1C")
|
||||||
|
|
||||||
tedee.parse_webhook_message.return_value = None
|
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))
|
locks_json = json.loads(load_fixture("locks.json", DOMAIN))
|
||||||
|
|
||||||
|
@ -78,7 +85,6 @@ async def init_integration(
|
||||||
) -> MockConfigEntry:
|
) -> MockConfigEntry:
|
||||||
"""Set up the Tedee integration for testing."""
|
"""Set up the Tedee integration for testing."""
|
||||||
mock_config_entry.add_to_hass(hass)
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
"""Test the Tedee config flow."""
|
"""Test the Tedee config flow."""
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from pytedee_async import (
|
from pytedee_async import (
|
||||||
TedeeClientException,
|
TedeeClientException,
|
||||||
|
@ -11,10 +11,12 @@ import pytest
|
||||||
|
|
||||||
from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN
|
from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN
|
||||||
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER
|
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.core import HomeAssistant
|
||||||
from homeassistant.data_entry_flow import FlowResultType
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
|
|
||||||
|
from .conftest import WEBHOOK_ID
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
FLOW_UNIQUE_ID = "112233445566778899"
|
FLOW_UNIQUE_ID = "112233445566778899"
|
||||||
|
@ -23,25 +25,30 @@ LOCAL_ACCESS_TOKEN = "api_token"
|
||||||
|
|
||||||
async def test_flow(hass: HomeAssistant, mock_tedee: MagicMock) -> None:
|
async def test_flow(hass: HomeAssistant, mock_tedee: MagicMock) -> None:
|
||||||
"""Test config flow with one bridge."""
|
"""Test config flow with one bridge."""
|
||||||
result = await hass.config_entries.flow.async_init(
|
with patch(
|
||||||
DOMAIN, context={"source": SOURCE_USER}
|
"homeassistant.components.tedee.config_flow.webhook_generate_id",
|
||||||
)
|
return_value=WEBHOOK_ID,
|
||||||
await hass.async_block_till_done()
|
):
|
||||||
assert result["type"] is FlowResultType.FORM
|
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(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"],
|
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_HOST: "192.168.1.62",
|
||||||
CONF_LOCAL_ACCESS_TOKEN: "token",
|
CONF_LOCAL_ACCESS_TOKEN: "token",
|
||||||
},
|
CONF_WEBHOOK_ID: WEBHOOK_ID,
|
||||||
)
|
}
|
||||||
|
|
||||||
assert result2["type"] is FlowResultType.CREATE_ENTRY
|
|
||||||
assert result2["data"] == {
|
|
||||||
CONF_HOST: "192.168.1.62",
|
|
||||||
CONF_LOCAL_ACCESS_TOKEN: "token",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_flow_already_configured(
|
async def test_flow_already_configured(
|
||||||
|
|
|
@ -1,16 +1,29 @@
|
||||||
"""Test initialization of tedee."""
|
"""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
|
import pytest
|
||||||
from syrupy import SnapshotAssertion
|
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.config_entries import ConfigEntryState
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
|
|
||||||
|
from .conftest import WEBHOOK_ID
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
|
from tests.typing import ClientSessionGenerator
|
||||||
|
|
||||||
|
|
||||||
async def test_load_unload_config_entry(
|
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
|
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(
|
async def test_bridge_device(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry: MockConfigEntry,
|
mock_config_entry: MockConfigEntry,
|
||||||
|
@ -68,3 +155,97 @@ async def test_bridge_device(
|
||||||
)
|
)
|
||||||
assert device
|
assert device
|
||||||
assert device == snapshot
|
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
|
||||||
|
|
|
@ -2,9 +2,10 @@
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from freezegun.api import FrozenDateTimeFactory
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
from pytedee_async import TedeeLock
|
from pytedee_async import TedeeLock, TedeeLockState
|
||||||
from pytedee_async.exception import (
|
from pytedee_async.exception import (
|
||||||
TedeeClientException,
|
TedeeClientException,
|
||||||
TedeeDataUpdateException,
|
TedeeDataUpdateException,
|
||||||
|
@ -18,15 +19,21 @@ from homeassistant.components.lock import (
|
||||||
SERVICE_LOCK,
|
SERVICE_LOCK,
|
||||||
SERVICE_OPEN,
|
SERVICE_OPEN,
|
||||||
SERVICE_UNLOCK,
|
SERVICE_UNLOCK,
|
||||||
|
STATE_LOCKED,
|
||||||
STATE_LOCKING,
|
STATE_LOCKING,
|
||||||
|
STATE_UNLOCKED,
|
||||||
STATE_UNLOCKING,
|
STATE_UNLOCKING,
|
||||||
)
|
)
|
||||||
|
from homeassistant.components.webhook import async_generate_url
|
||||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE
|
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
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.common import MockConfigEntry, async_fire_time_changed
|
||||||
|
from tests.typing import ClientSessionGenerator
|
||||||
|
|
||||||
pytestmark = pytest.mark.usefixtures("init_integration")
|
pytestmark = pytest.mark.usefixtures("init_integration")
|
||||||
|
|
||||||
|
@ -267,3 +274,32 @@ async def test_new_lock(
|
||||||
assert state
|
assert state
|
||||||
state = hass.states.get("lock.lock_6g7h")
|
state = hass.states.get("lock.lock_6g7h")
|
||||||
assert state
|
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
|
||||||
|
|
Loading…
Add table
Reference in a new issue