Support cloudhooks in Withings (#100916)

* Support cloudhooks in Withings

* Support cloudhooks in Withings

* Support cloudhooks in Withings

* Remove strings
This commit is contained in:
Joost Lekkerkerker 2023-09-26 21:52:18 +02:00 committed by GitHub
parent 42b006a108
commit 0f95de997f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 421 additions and 189 deletions

View file

@ -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})

View file

@ -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):

View file

@ -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,
),
)

View file

@ -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"

View file

@ -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:

View file

@ -1,6 +1,7 @@
{
"domain": "withings",
"name": "Withings",
"after_dependencies": ["cloud"],
"codeowners": ["@vangorra", "@joostlek"],
"config_flow": true,
"dependencies": ["application_credentials", "http", "webhook"],

View file

@ -22,15 +22,6 @@
"default": "Successfully authenticated with Withings."
}
},
"options": {
"step": {
"init": {
"data": {
"use_webhook": "Use webhooks"
}
}
}
},
"entity": {
"binary_sensor": {
"in_bed": {

View file

@ -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,
}
},
)

View file

@ -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."""

View file

@ -0,0 +1,3 @@
{
"profiles": []
}

View file

@ -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
}

View file

@ -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(

View file

@ -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}

View file

@ -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)

View file

@ -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))