From 0f95de997f4b27ffcbf3899ffb2eef0be9d29e35 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 26 Sep 2023 21:52:18 +0200 Subject: [PATCH] Support cloudhooks in Withings (#100916) * Support cloudhooks in Withings * Support cloudhooks in Withings * Support cloudhooks in Withings * Remove strings --- homeassistant/components/withings/__init__.py | 136 ++++++--- .../components/withings/binary_sensor.py | 9 +- .../components/withings/config_flow.py | 36 +-- homeassistant/components/withings/const.py | 1 + .../components/withings/coordinator.py | 27 +- .../components/withings/manifest.json | 1 + .../components/withings/strings.json | 9 - tests/components/withings/__init__.py | 28 +- tests/components/withings/conftest.py | 30 +- .../withings/fixtures/empty_notify_list.json | 3 + .../withings/fixtures/notify_list.json | 4 +- .../components/withings/test_binary_sensor.py | 9 +- tests/components/withings/test_config_flow.py | 33 +-- tests/components/withings/test_init.py | 277 ++++++++++++++++-- tests/components/withings/test_sensor.py | 7 +- 15 files changed, 421 insertions(+), 189 deletions(-) create mode 100644 tests/components/withings/fixtures/empty_notify_list.json diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 47c90e2b4d1..e9721719eef 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -5,21 +5,24 @@ For more details about this platform, please refer to the documentation at from __future__ import annotations from collections.abc import Awaitable, Callable +from datetime import datetime from aiohttp.hdrs import METH_HEAD, METH_POST from aiohttp.web import Request, Response import voluptuous as vol from withings_api.common import NotifyAppli -from homeassistant.components import webhook +from homeassistant.components import cloud from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) from homeassistant.components.http import HomeAssistantView from homeassistant.components.webhook import ( - async_generate_id, - async_unregister as async_unregister_webhook, + async_generate_id as webhook_generate_id, + async_generate_url as webhook_generate_url, + async_register as webhook_register, + async_unregister as webhook_unregister, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -27,15 +30,24 @@ from homeassistant.const import ( CONF_CLIENT_SECRET, CONF_TOKEN, CONF_WEBHOOK_ID, + EVENT_HOMEASSISTANT_STARTED, + EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CoreState, Event, HomeAssistant, ServiceCall from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType from .api import ConfigEntryWithingsApi -from .const import CONF_PROFILES, CONF_USE_WEBHOOK, CONFIG, DOMAIN, LOGGER +from .const import ( + CONF_CLOUDHOOK_URL, + CONF_PROFILES, + CONF_USE_WEBHOOK, + CONFIG, + DOMAIN, + LOGGER, +) from .coordinator import WithingsDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -100,24 +112,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Withings from a config entry.""" - if CONF_USE_WEBHOOK not in entry.options: + if CONF_WEBHOOK_ID not in entry.data or entry.unique_id is None: new_data = entry.data.copy() - new_options = { - CONF_USE_WEBHOOK: new_data.get(CONF_USE_WEBHOOK, False), - } unique_id = str(entry.data[CONF_TOKEN]["userid"]) if CONF_WEBHOOK_ID not in new_data: - new_data[CONF_WEBHOOK_ID] = async_generate_id() + new_data[CONF_WEBHOOK_ID] = webhook_generate_id() hass.config_entries.async_update_entry( - entry, data=new_data, options=new_options, unique_id=unique_id + entry, data=new_data, unique_id=unique_id ) - if ( - use_webhook := hass.data[DOMAIN][CONFIG].get(CONF_USE_WEBHOOK) - ) is not None and use_webhook != entry.options[CONF_USE_WEBHOOK]: - new_options = entry.options.copy() - new_options |= {CONF_USE_WEBHOOK: use_webhook} - hass.config_entries.async_update_entry(entry, options=new_options) client = ConfigEntryWithingsApi( hass=hass, @@ -126,28 +129,66 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, entry ), ) - - use_webhooks = entry.options[CONF_USE_WEBHOOK] - coordinator = WithingsDataUpdateCoordinator(hass, client, use_webhooks) - if use_webhooks: - - @callback - def async_call_later_callback(now) -> None: - hass.async_create_task(coordinator.async_subscribe_webhooks()) - - entry.async_on_unload(async_call_later(hass, 1, async_call_later_callback)) - webhook.async_register( - hass, - DOMAIN, - "Withings notify", - entry.data[CONF_WEBHOOK_ID], - get_webhook_handler(coordinator), - ) + coordinator = WithingsDataUpdateCoordinator(hass, client) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + async def unregister_webhook( + call_or_event_or_dt: ServiceCall | Event | datetime | None, + ) -> None: + LOGGER.debug("Unregister Withings webhook (%s)", entry.data[CONF_WEBHOOK_ID]) + webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + await hass.data[DOMAIN][entry.entry_id].async_unsubscribe_webhooks() + + async def register_webhook( + call_or_event_or_dt: ServiceCall | Event | datetime | None, + ) -> None: + if cloud.async_active_subscription(hass): + webhook_url = await async_cloudhook_generate_url(hass, entry) + else: + webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID]) + + if not webhook_url.startswith("https://"): + LOGGER.warning( + "Webhook not registered - " + "https and port 443 is required to register the webhook" + ) + return + + webhook_register( + hass, + DOMAIN, + "Withings", + entry.data[CONF_WEBHOOK_ID], + get_webhook_handler(coordinator), + ) + + await hass.data[DOMAIN][entry.entry_id].async_subscribe_webhooks(webhook_url) + LOGGER.debug("Register Withings webhook: %s", webhook_url) + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) + ) + + async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: + if state is cloud.CloudConnectionState.CLOUD_CONNECTED: + await register_webhook(None) + + if state is cloud.CloudConnectionState.CLOUD_DISCONNECTED: + await unregister_webhook(None) + async_call_later(hass, 30, register_webhook) + + if cloud.async_active_subscription(hass): + if cloud.async_is_connected(hass): + await register_webhook(None) + cloud.async_listen_connection_change(hass, manage_cloudhook) + + elif hass.state == CoreState.running: + await register_webhook(None) + else: + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, register_webhook) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -156,8 +197,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Withings config entry.""" - if entry.options[CONF_USE_WEBHOOK]: - async_unregister_webhook(hass, entry.data[CONF_WEBHOOK_ID]) + webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) @@ -169,6 +209,30 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) +async def async_cloudhook_generate_url(hass: HomeAssistant, entry: ConfigEntry) -> str: + """Generate the full URL for a webhook_id.""" + if CONF_CLOUDHOOK_URL not in entry.data: + webhook_url = await cloud.async_create_cloudhook( + hass, entry.data[CONF_WEBHOOK_ID] + ) + data = {**entry.data, CONF_CLOUDHOOK_URL: webhook_url} + hass.config_entries.async_update_entry(entry, data=data) + return webhook_url + return str(entry.data[CONF_CLOUDHOOK_URL]) + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Cleanup when entry is removed.""" + if cloud.async_active_subscription(hass): + try: + LOGGER.debug( + "Removing Withings cloudhook (%s)", entry.data[CONF_WEBHOOK_ID] + ) + await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID]) + except cloud.CloudNotAvailable: + pass + + def json_message_response(message: str, message_code: int) -> Response: """Produce common json output.""" return HomeAssistantView.json({"message": message, "code": message_code}) diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index a6e19d3ef86..309ef45623f 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -47,12 +47,11 @@ async def async_setup_entry( """Set up the sensor config entry.""" coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - if coordinator.use_webhooks: - entities = [ - WithingsBinarySensor(coordinator, attribute) for attribute in BINARY_SENSORS - ] + entities = [ + WithingsBinarySensor(coordinator, attribute) for attribute in BINARY_SENSORS + ] - async_add_entities(entities) + async_add_entities(entities) class WithingsBinarySensor(WithingsEntity, BinarySensorEntity): diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index 4dd123468a0..35a4582ae4d 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -5,13 +5,11 @@ from collections.abc import Mapping import logging from typing import Any -import voluptuous as vol from withings_api.common import AuthScope from homeassistant.components.webhook import async_generate_id -from homeassistant.config_entries import ConfigEntry, OptionsFlowWithConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, CONF_WEBHOOK_ID -from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow @@ -27,14 +25,6 @@ class WithingsFlowHandler( reauth_entry: ConfigEntry | None = None - @staticmethod - @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> WithingsOptionsFlowHandler: - """Get the options flow for this handler.""" - return WithingsOptionsFlowHandler(config_entry) - @property def logger(self) -> logging.Logger: """Return logger.""" @@ -83,27 +73,9 @@ class WithingsFlowHandler( ) if self.reauth_entry.unique_id == user_id: - self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) + self.hass.config_entries.async_update_entry( + self.reauth_entry, data={**self.reauth_entry.data, **data} + ) return self.async_abort(reason="reauth_successful") return self.async_abort(reason="wrong_account") - - -class WithingsOptionsFlowHandler(OptionsFlowWithConfigEntry): - """Withings Options flow handler.""" - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Initialize form.""" - if user_input is not None: - return self.async_create_entry( - data=user_input, - ) - return self.async_show_form( - step_id="init", - data_schema=self.add_suggested_values_to_schema( - vol.Schema({vol.Required(CONF_USE_WEBHOOK): bool}), - self.options, - ), - ) diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index 545c7bfcb26..6129e0c4b29 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -5,6 +5,7 @@ import logging DEFAULT_TITLE = "Withings" CONF_PROFILES = "profiles" CONF_USE_WEBHOOK = "use_webhook" +CONF_CLOUDHOOK_URL = "cloudhook_url" DATA_MANAGER = "data_manager" diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 08d330f7d5b..128d4e39193 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -15,9 +15,7 @@ from withings_api.common import ( query_measure_groups, ) -from homeassistant.components.webhook import async_generate_url from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -72,6 +70,8 @@ WITHINGS_MEASURE_TYPE_MAP: dict[ NotifyAppli.BED_IN: Measurement.IN_BED, } +UPDATE_INTERVAL = timedelta(minutes=10) + class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any]]): """Base coordinator.""" @@ -79,21 +79,12 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any] in_bed: bool | None = None config_entry: ConfigEntry - def __init__( - self, hass: HomeAssistant, client: ConfigEntryWithingsApi, use_webhooks: bool - ) -> None: + def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None: """Initialize the Withings data coordinator.""" - update_interval: timedelta | None = timedelta(minutes=10) - if use_webhooks: - update_interval = None - super().__init__(hass, LOGGER, name="Withings", update_interval=update_interval) + super().__init__(hass, LOGGER, name="Withings", update_interval=UPDATE_INTERVAL) self._client = client - self._webhook_url = async_generate_url( - hass, self.config_entry.data[CONF_WEBHOOK_ID] - ) - self.use_webhooks = use_webhooks - async def async_subscribe_webhooks(self) -> None: + async def async_subscribe_webhooks(self, webhook_url: str) -> None: """Subscribe to webhooks.""" await self.async_unsubscribe_webhooks() @@ -102,7 +93,7 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any] subscribed_notifications = frozenset( profile.appli for profile in current_webhooks.profiles - if profile.callbackurl == self._webhook_url + if profile.callbackurl == webhook_url ) notification_to_subscribe = ( @@ -114,14 +105,15 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any] for notification in notification_to_subscribe: LOGGER.debug( "Subscribing %s for %s in %s seconds", - self._webhook_url, + webhook_url, notification, SUBSCRIBE_DELAY.total_seconds(), ) # Withings will HTTP HEAD the callback_url and needs some downtime # between each call or there is a higher chance of failure. await asyncio.sleep(SUBSCRIBE_DELAY.total_seconds()) - await self._client.async_notify_subscribe(self._webhook_url, notification) + await self._client.async_notify_subscribe(webhook_url, notification) + self.update_interval = None async def async_unsubscribe_webhooks(self) -> None: """Unsubscribe to webhooks.""" @@ -140,6 +132,7 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any] await self._client.async_notify_revoke( webhook_configuration.callbackurl, webhook_configuration.appli ) + self.update_interval = UPDATE_INTERVAL async def _async_update_data(self) -> dict[Measurement, Any]: try: diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index 325205cb4d4..edc8aab83b7 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -1,6 +1,7 @@ { "domain": "withings", "name": "Withings", + "after_dependencies": ["cloud"], "codeowners": ["@vangorra", "@joostlek"], "config_flow": true, "dependencies": ["application_credentials", "http", "webhook"], diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 22718b305ec..ea925f535e3 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -22,15 +22,6 @@ "default": "Successfully authenticated with Withings." } }, - "options": { - "step": { - "init": { - "data": { - "use_webhook": "Use webhooks" - } - } - } - }, "entity": { "binary_sensor": { "in_bed": { diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index e6fb24244d6..459deaae4c5 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -6,10 +6,8 @@ from urllib.parse import urlparse from aiohttp.test_utils import TestClient from homeassistant.components.webhook import async_generate_url -from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -42,26 +40,16 @@ async def call_webhook( return WebhookResponse(message=data["message"], message_code=data["code"]) -async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: +async def setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry, enable_webhooks: bool = True +) -> None: """Fixture for setting up the component.""" config_entry.add_to_hass(hass) - await async_process_ha_core_config( - hass, - {"external_url": "http://example.local:8123"}, - ) + if enable_webhooks: + await async_process_ha_core_config( + hass, + {"external_url": "https://example.local:8123"}, + ) await hass.config_entries.async_setup(config_entry.entry_id) - - -async def enable_webhooks(hass: HomeAssistant) -> None: - """Enable webhooks.""" - assert await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: { - CONF_USE_WEBHOOK: True, - } - }, - ) diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index e7777d470a5..3fc2a3c6461 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -79,8 +79,29 @@ def webhook_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: "profile": TITLE, "webhook_id": WEBHOOK_ID, }, - options={ - "use_webhook": True, + ) + + +@pytest.fixture +def cloudhook_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: + """Create Withings entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id=str(USER_ID), + data={ + "auth_implementation": DOMAIN, + "token": { + "status": 0, + "userid": str(USER_ID), + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": ",".join(scopes), + }, + "profile": TITLE, + "webhook_id": WEBHOOK_ID, + "cloudhook_url": "https://hooks.nabu.casa/ABCD", }, ) @@ -105,9 +126,6 @@ def polling_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: "profile": TITLE, "webhook_id": WEBHOOK_ID, }, - options={ - "use_webhook": False, - }, ) @@ -136,7 +154,7 @@ def mock_withings(): yield mock -@pytest.fixture(name="disable_webhook_delay") +@pytest.fixture(name="disable_webhook_delay", autouse=True) def disable_webhook_delay(): """Disable webhook delay.""" diff --git a/tests/components/withings/fixtures/empty_notify_list.json b/tests/components/withings/fixtures/empty_notify_list.json new file mode 100644 index 00000000000..c905c95e4cb --- /dev/null +++ b/tests/components/withings/fixtures/empty_notify_list.json @@ -0,0 +1,3 @@ +{ + "profiles": [] +} diff --git a/tests/components/withings/fixtures/notify_list.json b/tests/components/withings/fixtures/notify_list.json index bc696db583a..5b368a5c979 100644 --- a/tests/components/withings/fixtures/notify_list.json +++ b/tests/components/withings/fixtures/notify_list.json @@ -8,13 +8,13 @@ }, { "appli": 50, - "callbackurl": "http://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", + "callbackurl": "https://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", "expires": 2147483647, "comment": null }, { "appli": 51, - "callbackurl": "http://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", + "callbackurl": "https://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", "expires": 2147483647, "comment": null } diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py index 8e641925d60..d258986bdaf 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -8,7 +8,7 @@ from withings_api.common import NotifyAppli from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from . import call_webhook, enable_webhooks, setup_integration +from . import call_webhook, setup_integration from .conftest import USER_ID, WEBHOOK_ID from tests.common import MockConfigEntry @@ -18,12 +18,10 @@ from tests.typing import ClientSessionGenerator async def test_binary_sensor( hass: HomeAssistant, withings: AsyncMock, - disable_webhook_delay, webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test binary sensor.""" - await enable_webhooks(hass) await setup_integration(hass, webhook_config_entry) client = await hass_client_no_auth() @@ -56,18 +54,17 @@ async def test_binary_sensor( async def test_polling_binary_sensor( hass: HomeAssistant, withings: AsyncMock, - disable_webhook_delay, polling_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test binary sensor.""" - await setup_integration(hass, polling_config_entry) + await setup_integration(hass, polling_config_entry, False) client = await hass_client_no_auth() entity_id = "binary_sensor.henk_in_bed" - assert hass.states.get(entity_id) is None + assert hass.states.get(entity_id).state == STATE_UNKNOWN with pytest.raises(ClientResponseError): await call_webhook( diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index 1fc26824d45..36edffcc346 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -1,7 +1,7 @@ """Tests for config flow.""" from unittest.mock import AsyncMock, patch -from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN +from homeassistant.components.withings.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -84,7 +84,6 @@ async def test_config_non_unique_profile( current_request_with_host: None, withings: AsyncMock, polling_config_entry: MockConfigEntry, - disable_webhook_delay, aioclient_mock: AiohttpClientMocker, ) -> None: """Test setup a non-unique profile.""" @@ -138,7 +137,6 @@ async def test_config_reauth_profile( aioclient_mock: AiohttpClientMocker, polling_config_entry: MockConfigEntry, withings: AsyncMock, - disable_webhook_delay, current_request_with_host, ) -> None: """Test reauth an existing profile reauthenticates the config entry.""" @@ -201,7 +199,6 @@ async def test_config_reauth_wrong_account( aioclient_mock: AiohttpClientMocker, polling_config_entry: MockConfigEntry, withings: AsyncMock, - disable_webhook_delay, current_request_with_host, ) -> None: """Test reauth with wrong account.""" @@ -256,31 +253,3 @@ async def test_config_reauth_wrong_account( assert result assert result["type"] == FlowResultType.ABORT assert result["reason"] == "wrong_account" - - -async def test_options_flow( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - polling_config_entry: MockConfigEntry, - withings: AsyncMock, - disable_webhook_delay, - current_request_with_host, -) -> None: - """Test options flow.""" - await setup_integration(hass, polling_config_entry) - - result = await hass.config_entries.options.async_init(polling_config_entry.entry_id) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_USE_WEBHOOK: True}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == {CONF_USE_WEBHOOK: True} diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 6e5c10390ff..353dcee8a7c 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -1,26 +1,43 @@ """Tests for the Withings component.""" from datetime import timedelta from typing import Any -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from urllib.parse import urlparse from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol +from withings_api import NotifyListResponse from withings_api.common import AuthFailedException, NotifyAppli, UnauthorizedException from homeassistant import config_entries +from homeassistant.components.cloud import ( + SIGNAL_CLOUD_CONNECTION_STATE, + CloudConnectionState, + CloudNotAvailable, +) from homeassistant.components.webhook import async_generate_url from homeassistant.components.withings import CONFIG_SCHEMA, async_setup from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_WEBHOOK_ID -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_WEBHOOK_ID, + EVENT_HOMEASSISTANT_STARTED, +) +from homeassistant.core import CoreState, HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util import dt as dt_util -from . import call_webhook, enable_webhooks, setup_integration +from . import call_webhook, setup_integration from .conftest import USER_ID, WEBHOOK_ID -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, +) +from tests.components.cloud import mock_cloud from tests.typing import ClientSessionGenerator @@ -108,12 +125,10 @@ async def test_async_setup_no_config(hass: HomeAssistant) -> None: async def test_data_manager_webhook_subscription( hass: HomeAssistant, withings: AsyncMock, - disable_webhook_delay, webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test data manager webhook subscriptions.""" - await enable_webhooks(hass) await setup_integration(hass, webhook_config_entry) await hass_client_no_auth() await hass.async_block_till_done() @@ -122,7 +137,7 @@ async def test_data_manager_webhook_subscription( assert withings.async_notify_subscribe.call_count == 4 - webhook_url = "http://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" + webhook_url = "https://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.WEIGHT) withings.async_notify_subscribe.assert_any_call( @@ -138,7 +153,6 @@ async def test_data_manager_webhook_subscription( async def test_webhook_subscription_polling_config( hass: HomeAssistant, withings: AsyncMock, - disable_webhook_delay, polling_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, freezer: FrozenDateTimeFactory, @@ -169,10 +183,8 @@ async def test_requests( webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, method: str, - disable_webhook_delay, ) -> None: """Test we handle request methods Withings sends.""" - await enable_webhooks(hass) await setup_integration(hass, webhook_config_entry) client = await hass_client_no_auth() webhook_url = async_generate_url(hass, WEBHOOK_ID) @@ -189,10 +201,8 @@ async def test_webhooks_request_data( withings: AsyncMock, webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, - disable_webhook_delay, ) -> None: """Test calling a webhook requests data.""" - await enable_webhooks(hass) await setup_integration(hass, webhook_config_entry) client = await hass_client_no_auth() @@ -207,6 +217,35 @@ async def test_webhooks_request_data( assert withings.async_measure_get_meas.call_count == 2 +async def test_delayed_startup( + hass: HomeAssistant, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test delayed start up.""" + hass.state = CoreState.not_running + await setup_integration(hass, webhook_config_entry) + + withings.async_notify_subscribe.assert_not_called() + client = await hass_client_no_auth() + + assert withings.async_measure_get_meas.call_count == 1 + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.WEIGHT}, + client, + ) + assert withings.async_measure_get_meas.call_count == 2 + + @pytest.mark.parametrize( "error", [ @@ -221,7 +260,7 @@ async def test_triggering_reauth( error: Exception, ) -> None: """Test triggering reauth.""" - await setup_integration(hass, polling_config_entry) + await setup_integration(hass, polling_config_entry, False) withings.async_measure_get_meas.side_effect = error future = dt_util.utcnow() + timedelta(minutes=10) @@ -275,9 +314,211 @@ async def test_config_flow_upgrade( assert entry.unique_id == "123" assert entry.data["token"]["userid"] == 123 assert CONF_WEBHOOK_ID in entry.data - assert entry.options == { - "use_webhook": False, - } + + +async def test_setup_with_cloudhook( + hass: HomeAssistant, cloudhook_config_entry: MockConfigEntry, withings: AsyncMock +) -> None: + """Test if set up with active cloud subscription and cloud hook.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.cloud.async_delete_cloudhook" + ) as fake_delete_cloudhook, patch( + "homeassistant.components.withings.webhook_generate_url" + ): + await setup_integration(hass, cloudhook_config_entry) + assert hass.components.cloud.async_active_subscription() is True + + assert ( + hass.config_entries.async_entries(DOMAIN)[0].data["cloudhook_url"] + == "https://hooks.nabu.casa/ABCD" + ) + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN) + fake_create_cloudhook.assert_not_called() + + for config_entry in hass.config_entries.async_entries(DOMAIN): + await hass.config_entries.async_remove(config_entry.entry_id) + fake_delete_cloudhook.assert_called_once() + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + + +async def test_removing_entry_with_cloud_unavailable( + hass: HomeAssistant, cloudhook_config_entry: MockConfigEntry, withings: AsyncMock +) -> None: + """Test handling cloud unavailable when deleting entry.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ), patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.cloud.async_delete_cloudhook", + side_effect=CloudNotAvailable(), + ), patch( + "homeassistant.components.withings.webhook_generate_url" + ): + await setup_integration(hass, cloudhook_config_entry) + assert hass.components.cloud.async_active_subscription() is True + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN) + + for config_entry in hass.config_entries.async_entries(DOMAIN): + await hass.config_entries.async_remove(config_entry.entry_id) + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + + +async def test_setup_with_cloud( + hass: HomeAssistant, webhook_config_entry: MockConfigEntry, withings: AsyncMock +) -> None: + """Test if set up with active cloud subscription.""" + await mock_cloud(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.cloud.async_delete_cloudhook" + ) as fake_delete_cloudhook, patch( + "homeassistant.components.withings.webhook_generate_url" + ): + await setup_integration(hass, webhook_config_entry) + assert hass.components.cloud.async_active_subscription() is True + assert hass.components.cloud.async_is_connected() is True + fake_create_cloudhook.assert_called_once() + + assert ( + hass.config_entries.async_entries("withings")[0].data["cloudhook_url"] + == "https://hooks.nabu.casa/ABCD" + ) + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN) + + for config_entry in hass.config_entries.async_entries("withings"): + await hass.config_entries.async_remove(config_entry.entry_id) + fake_delete_cloudhook.assert_called_once() + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + + +async def test_setup_without_https( + hass: HomeAssistant, + webhook_config_entry: MockConfigEntry, + withings: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test if set up with cloud link and without https.""" + hass.config.components.add("cloud") + with patch( + "homeassistant.helpers.network.get_url", + return_value="http://example.nabu.casa", + ), patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.withings.webhook_generate_url" + ) as mock_async_generate_url: + mock_async_generate_url.return_value = "http://example.com" + await setup_integration(hass, webhook_config_entry) + + await hass.async_block_till_done() + mock_async_generate_url.assert_called_once() + + assert "https and port 443 is required to register the webhook" in caplog.text + + +async def test_cloud_disconnect( + hass: HomeAssistant, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test disconnecting from the cloud.""" + await mock_cloud(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ), patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.cloud.async_delete_cloudhook" + ), patch( + "homeassistant.components.withings.webhook_generate_url" + ): + await setup_integration(hass, webhook_config_entry) + assert hass.components.cloud.async_active_subscription() is True + assert hass.components.cloud.async_is_connected() is True + + await hass.async_block_till_done() + + withings.async_notify_list.return_value = NotifyListResponse( + **load_json_object_fixture("withings/empty_notify_list.json") + ) + + assert withings.async_notify_subscribe.call_count == 6 + + async_dispatcher_send( + hass, SIGNAL_CLOUD_CONNECTION_STATE, CloudConnectionState.CLOUD_DISCONNECTED + ) + await hass.async_block_till_done() + + assert withings.async_notify_revoke.call_count == 3 + + async_dispatcher_send( + hass, SIGNAL_CLOUD_CONNECTION_STATE, CloudConnectionState.CLOUD_CONNECTED + ) + await hass.async_block_till_done() + + assert withings.async_notify_subscribe.call_count == 12 @pytest.mark.parametrize( @@ -300,13 +541,11 @@ async def test_webhook_post( withings: AsyncMock, webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, - disable_webhook_delay, body: dict[str, Any], expected_code: int, current_request_with_host: None, ) -> None: """Test webhook callback.""" - await enable_webhooks(hass) await setup_integration(hass, webhook_config_entry) client = await hass_client_no_auth() webhook_url = async_generate_url(hass, WEBHOOK_ID) diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index b0df6e4c3c2..fe640e315a0 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry -from . import call_webhook, enable_webhooks, setup_integration +from . import call_webhook, setup_integration from .conftest import USER_ID, WEBHOOK_ID from tests.common import MockConfigEntry, async_fire_time_changed @@ -95,11 +95,9 @@ async def test_sensor_default_enabled_entities( hass: HomeAssistant, withings: AsyncMock, webhook_config_entry: MockConfigEntry, - disable_webhook_delay, hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test entities enabled by default.""" - await enable_webhooks(hass) await setup_integration(hass, webhook_config_entry) entity_registry: EntityRegistry = er.async_get(hass) @@ -137,7 +135,6 @@ async def test_all_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, withings: AsyncMock, - disable_webhook_delay, polling_config_entry: MockConfigEntry, ) -> None: """Test all entities.""" @@ -156,7 +153,7 @@ async def test_update_failed( freezer: FrozenDateTimeFactory, ) -> None: """Test all entities.""" - await setup_integration(hass, polling_config_entry) + await setup_integration(hass, polling_config_entry, False) withings.async_measure_get_meas.side_effect = Exception freezer.tick(timedelta(minutes=10))