From 4f63c7934ba1f6991e04e8ea5b3869064a2a4685 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 26 Sep 2023 09:17:11 +0200 Subject: [PATCH] Add coordinator to Withings (#100378) * Add coordinator to Withings * Add coordinator to Withings * Fix tests * Remove common files * Fix tests * Fix tests * Rename to Entity * Fix * Rename webhook handler * Fix * Fix external url * Update homeassistant/components/withings/entity.py Co-authored-by: Luke Lashley * Update homeassistant/components/withings/entity.py Co-authored-by: Luke Lashley * Update homeassistant/components/withings/entity.py Co-authored-by: Luke Lashley * Update homeassistant/components/withings/entity.py Co-authored-by: Luke Lashley * fix imports * Simplify * Simplify * Fix feedback * Test if this makes changes clearer * Test if this makes changes clearer * Fix tests * Remove name * Fix feedback --------- Co-authored-by: Luke Lashley --- homeassistant/components/withings/__init__.py | 144 ++++--- .../components/withings/binary_sensor.py | 23 +- homeassistant/components/withings/common.py | 370 ++++-------------- homeassistant/components/withings/entity.py | 78 +--- homeassistant/components/withings/sensor.py | 56 +-- tests/components/withings/__init__.py | 8 +- tests/components/withings/common.py | 328 ---------------- tests/components/withings/conftest.py | 54 +-- .../components/withings/test_binary_sensor.py | 35 +- tests/components/withings/test_config_flow.py | 26 +- tests/components/withings/test_init.py | 96 ++++- tests/components/withings/test_sensor.py | 52 ++- 12 files changed, 394 insertions(+), 876 deletions(-) delete mode 100644 tests/components/withings/common.py diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 5e7337086392e..1c66115d9b5e7 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -4,8 +4,9 @@ """ from __future__ import annotations -import asyncio +from collections.abc import Awaitable, Callable +from aiohttp.hdrs import METH_HEAD, METH_POST from aiohttp.web import Request, Response import voluptuous as vol from withings_api.common import NotifyAppli @@ -15,6 +16,7 @@ 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, @@ -28,17 +30,13 @@ Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +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 . import const -from .common import ( - async_get_data_manager, - async_remove_data_manager, - get_data_manager_by_webhook_id, - json_message_response, -) +from .api import ConfigEntryWithingsApi +from .common import WithingsDataUpdateCoordinator from .const import CONF_USE_WEBHOOK, CONFIG, LOGGER DOMAIN = const.DOMAIN @@ -56,7 +54,7 @@ vol.Optional(CONF_CLIENT_SECRET): vol.All( cv.string, vol.Length(min=1) ), - vol.Optional(const.CONF_USE_WEBHOOK, default=False): cv.boolean, + vol.Optional(const.CONF_USE_WEBHOOK): cv.boolean, vol.Optional(const.CONF_PROFILES): vol.All( cv.ensure_list, vol.Unique(), @@ -116,37 +114,41 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry( entry, data=new_data, options=new_options, unique_id=unique_id ) - use_webhook = hass.data[DOMAIN][CONFIG][CONF_USE_WEBHOOK] - if use_webhook is not None and use_webhook != entry.options[CONF_USE_WEBHOOK]: + 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) - data_manager = await async_get_data_manager(hass, entry) - - LOGGER.debug("Confirming %s is authenticated to withings", entry.title) - await data_manager.poll_data_update_coordinator.async_config_entry_first_refresh() - - webhook.async_register( - hass, - const.DOMAIN, - "Withings notify", - data_manager.webhook_config.id, - async_webhook_handler, + client = ConfigEntryWithingsApi( + hass=hass, + config_entry=entry, + implementation=await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ), ) - # Perform first webhook subscription check. - if data_manager.webhook_config.enabled: - data_manager.async_start_polling_webhook_subscriptions() + 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( - data_manager.subscription_update_coordinator.async_refresh() - ) + hass.async_create_task(coordinator.async_subscribe_webhooks()) - # Start subscription check in the background, outside this component's setup. 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), + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -156,19 +158,12 @@ def async_call_later_callback(now) -> None: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Withings config entry.""" - data_manager = await async_get_data_manager(hass, entry) - data_manager.async_stop_polling_webhook_subscriptions() - - async_unregister_webhook(hass, data_manager.webhook_config.id) + if entry.options[CONF_USE_WEBHOOK]: + async_unregister_webhook(hass, entry.data[CONF_WEBHOOK_ID]) - await asyncio.gather( - data_manager.async_unsubscribe_webhook(), - hass.config_entries.async_unload_platforms(entry, PLATFORMS), - ) - - async_remove_data_manager(hass, entry) - - return True + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: @@ -176,44 +171,45 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) -async def async_webhook_handler( - hass: HomeAssistant, webhook_id: str, request: Request -) -> Response | None: - """Handle webhooks calls.""" - # Handle http head calls to the path. - # When creating a notify subscription, Withings will check that the endpoint is running by sending a HEAD request. - if request.method.upper() == "HEAD": - return Response() +def json_message_response(message: str, message_code: int) -> Response: + """Produce common json output.""" + return HomeAssistantView.json({"message": message, "code": message_code}) - if request.method.upper() != "POST": - return json_message_response("Invalid method", message_code=2) - # Handle http post calls to the path. - if not request.body_exists: - return json_message_response("No request body", message_code=12) +def get_webhook_handler( + coordinator: WithingsDataUpdateCoordinator, +) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]: + """Return webhook handler.""" - params = await request.post() + async def async_webhook_handler( + hass: HomeAssistant, webhook_id: str, request: Request + ) -> Response | None: + # Handle http head calls to the path. + # When creating a notify subscription, Withings will check that the endpoint is running by sending a HEAD request. + if request.method == METH_HEAD: + return Response() - if "appli" not in params: - return json_message_response("Parameter appli not provided", message_code=20) + if request.method != METH_POST: + return json_message_response("Invalid method", message_code=2) - try: - appli = NotifyAppli(int(params.getone("appli"))) # type: ignore[arg-type] - except ValueError: - return json_message_response("Invalid appli provided", message_code=21) + # Handle http post calls to the path. + if not request.body_exists: + return json_message_response("No request body", message_code=12) - data_manager = get_data_manager_by_webhook_id(hass, webhook_id) - if not data_manager: - LOGGER.error( - ( - "Webhook id %s not handled by data manager. This is a bug and should be" - " reported" - ), - webhook_id, - ) - return json_message_response("User not found", message_code=1) + params = await request.post() + + if "appli" not in params: + return json_message_response( + "Parameter appli not provided", message_code=20 + ) + + try: + appli = NotifyAppli(int(params.getone("appli"))) # type: ignore[arg-type] + except ValueError: + return json_message_response("Invalid appli provided", message_code=21) + + await coordinator.async_webhook_data_updated(appli) - # Run this in the background and return immediately. - hass.async_create_task(data_manager.async_webhook_data_updated(appli)) + return json_message_response("Success", message_code=0) - return json_message_response("Success", message_code=0) + return async_webhook_handler diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 976774f23b34f..e12a0929c2aef 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -14,9 +14,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import UpdateType, async_get_data_manager -from .const import Measurement -from .entity import BaseWithingsSensor, WithingsEntityDescription +from .common import WithingsDataUpdateCoordinator +from .const import DOMAIN, Measurement +from .entity import WithingsEntity, WithingsEntityDescription @dataclass @@ -34,7 +34,6 @@ class WithingsBinarySensorEntityDescription( measure_type=NotifyAppli.BED_IN, translation_key="in_bed", icon="mdi:bed", - update_type=UpdateType.WEBHOOK, device_class=BinarySensorDeviceClass.OCCUPANCY, ), ] @@ -46,17 +45,17 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - data_manager = await async_get_data_manager(hass, entry) + coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - entities = [ - WithingsHealthBinarySensor(data_manager, attribute) - for attribute in BINARY_SENSORS - ] + if coordinator.use_webhooks: + entities = [ + WithingsBinarySensor(coordinator, attribute) for attribute in BINARY_SENSORS + ] - async_add_entities(entities, True) + async_add_entities(entities) -class WithingsHealthBinarySensor(BaseWithingsSensor, BinarySensorEntity): +class WithingsBinarySensor(WithingsEntity, BinarySensorEntity): """Implementation of a Withings sensor.""" entity_description: WithingsBinarySensorEntityDescription @@ -64,4 +63,4 @@ class WithingsHealthBinarySensor(BaseWithingsSensor, BinarySensorEntity): @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" - return self._state_data + return self.coordinator.in_bed diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 5f0090ad9a6cf..08d330f7d5b10 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -1,17 +1,9 @@ -"""Common code for Withings.""" -from __future__ import annotations - +"""Withings coordinator.""" import asyncio from collections.abc import Callable -from dataclasses import dataclass -import datetime from datetime import timedelta -from enum import IntEnum, StrEnum -from http import HTTPStatus -import re from typing import Any -from aiohttp.web import Response from withings_api.common import ( AuthFailedException, GetSleepSummaryField, @@ -23,43 +15,19 @@ query_measure_groups, ) -from homeassistant.components import webhook -from homeassistant.components.http import HomeAssistantView +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 CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util -from . import const from .api import ConfigEntryWithingsApi from .const import LOGGER, Measurement -NOT_AUTHENTICATED_ERROR = re.compile( - f"^{HTTPStatus.UNAUTHORIZED},.*", - re.IGNORECASE, -) -DATA_UPDATED_SIGNAL = "withings_entity_state_updated" -SUBSCRIBE_DELAY = datetime.timedelta(seconds=5) -UNSUBSCRIBE_DELAY = datetime.timedelta(seconds=1) - - -class UpdateType(StrEnum): - """Data update type.""" - - POLL = "poll" - WEBHOOK = "webhook" - - -@dataclass -class WebhookConfig: - """Config for a webhook.""" - - id: str - url: str - enabled: bool - +SUBSCRIBE_DELAY = timedelta(seconds=5) +UNSUBSCRIBE_DELAY = timedelta(seconds=1) WITHINGS_MEASURE_TYPE_MAP: dict[ NotifyAppli | GetSleepSummaryField | MeasureType, Measurement @@ -105,214 +73,91 @@ class WebhookConfig: } -def json_message_response(message: str, message_code: int) -> Response: - """Produce common json output.""" - return HomeAssistantView.json({"message": message, "code": message_code}) - - -class WebhookAvailability(IntEnum): - """Represents various statuses of webhook availability.""" - - SUCCESS = 0 - CONNECT_ERROR = 1 - HTTP_ERROR = 2 - NOT_WEBHOOK = 3 - +class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any]]): + """Base coordinator.""" -class WebhookUpdateCoordinator: - """Coordinates webhook data updates across listeners.""" - - def __init__(self, hass: HomeAssistant, user_id: int) -> None: - """Initialize the object.""" - self._hass = hass - self._user_id = user_id - self._listeners: list[CALLBACK_TYPE] = [] - self.data: dict[Measurement, Any] = {} - - def async_add_listener(self, listener: CALLBACK_TYPE) -> Callable[[], None]: - """Add a listener.""" - self._listeners.append(listener) - - @callback - def remove_listener() -> None: - self.async_remove_listener(listener) - - return remove_listener - - def async_remove_listener(self, listener: CALLBACK_TYPE) -> None: - """Remove a listener.""" - self._listeners.remove(listener) - - def update_data(self, measurement: Measurement, value: Any) -> None: - """Update the data object and notify listeners the data has changed.""" - self.data[measurement] = value - self.notify_data_changed() - - def notify_data_changed(self) -> None: - """Notify all listeners the data has changed.""" - for listener in self._listeners: - listener() - - -class DataManager: - """Manage withing data.""" + in_bed: bool | None = None + config_entry: ConfigEntry def __init__( - self, - hass: HomeAssistant, - api: ConfigEntryWithingsApi, - user_id: int, - webhook_config: WebhookConfig, + self, hass: HomeAssistant, client: ConfigEntryWithingsApi, use_webhooks: bool ) -> None: - """Initialize the data manager.""" - self._hass = hass - self._api = api - self._user_id = user_id - self._webhook_config = webhook_config - self._notify_subscribe_delay = SUBSCRIBE_DELAY - self._notify_unsubscribe_delay = UNSUBSCRIBE_DELAY - - self._is_available = True - self._cancel_interval_update_interval: CALLBACK_TYPE | None = None - self._cancel_configure_webhook_subscribe_interval: CALLBACK_TYPE | None = None - self._api_notification_id = f"withings_{self._user_id}" - - self.subscription_update_coordinator = DataUpdateCoordinator( - hass, - LOGGER, - name="subscription_update_coordinator", - update_interval=timedelta(minutes=120), - update_method=self.async_subscribe_webhook, + """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) + self._client = client + self._webhook_url = async_generate_url( + hass, self.config_entry.data[CONF_WEBHOOK_ID] ) - self.poll_data_update_coordinator = DataUpdateCoordinator[ - dict[MeasureType, Any] | None - ]( - hass, - LOGGER, - name="poll_data_update_coordinator", - update_interval=timedelta(minutes=120) - if self._webhook_config.enabled - else timedelta(minutes=10), - update_method=self.async_get_all_data, - ) - self.webhook_update_coordinator = WebhookUpdateCoordinator( - self._hass, self._user_id - ) - self._cancel_subscription_update: Callable[[], None] | None = None - self._subscribe_webhook_run_count = 0 - - @property - def webhook_config(self) -> WebhookConfig: - """Get the webhook config.""" - return self._webhook_config + self.use_webhooks = use_webhooks - @property - def user_id(self) -> int: - """Get the user_id of the authenticated user.""" - return self._user_id + async def async_subscribe_webhooks(self) -> None: + """Subscribe to webhooks.""" + await self.async_unsubscribe_webhooks() - def async_start_polling_webhook_subscriptions(self) -> None: - """Start polling webhook subscriptions (if enabled) to reconcile their setup.""" - self.async_stop_polling_webhook_subscriptions() + current_webhooks = await self._client.async_notify_list() - def empty_listener() -> None: - pass - - self._cancel_subscription_update = ( - self.subscription_update_coordinator.async_add_listener(empty_listener) - ) - - def async_stop_polling_webhook_subscriptions(self) -> None: - """Stop polling webhook subscriptions.""" - if self._cancel_subscription_update: - self._cancel_subscription_update() - self._cancel_subscription_update = None - - async def async_subscribe_webhook(self) -> None: - """Subscribe the webhook to withings data updates.""" - LOGGER.debug("Configuring withings webhook") - - # On first startup, perform a fresh re-subscribe. Withings stops pushing data - # if the webhook fails enough times but they don't remove the old subscription - # config. This ensures the subscription is setup correctly and they start - # pushing again. - if self._subscribe_webhook_run_count == 0: - LOGGER.debug("Refreshing withings webhook configs") - await self.async_unsubscribe_webhook() - self._subscribe_webhook_run_count += 1 - - # Get the current webhooks. - response = await self._api.async_notify_list() - - subscribed_applis = frozenset( + subscribed_notifications = frozenset( profile.appli - for profile in response.profiles - if profile.callbackurl == self._webhook_config.url + for profile in current_webhooks.profiles + if profile.callbackurl == self._webhook_url ) - # Determine what subscriptions need to be created. - ignored_applis = frozenset({NotifyAppli.USER, NotifyAppli.UNKNOWN}) - to_add_applis = frozenset( - appli - for appli in NotifyAppli - if appli not in subscribed_applis and appli not in ignored_applis + notification_to_subscribe = ( + set(NotifyAppli) + - subscribed_notifications + - {NotifyAppli.USER, NotifyAppli.UNKNOWN} ) - # Subscribe to each one. - for appli in to_add_applis: + for notification in notification_to_subscribe: LOGGER.debug( "Subscribing %s for %s in %s seconds", - self._webhook_config.url, - appli, - self._notify_subscribe_delay.total_seconds(), + self._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(self._notify_subscribe_delay.total_seconds()) - await self._api.async_notify_subscribe(self._webhook_config.url, appli) + await asyncio.sleep(SUBSCRIBE_DELAY.total_seconds()) + await self._client.async_notify_subscribe(self._webhook_url, notification) - async def async_unsubscribe_webhook(self) -> None: - """Unsubscribe webhook from withings data updates.""" - # Get the current webhooks. - response = await self._api.async_notify_list() + async def async_unsubscribe_webhooks(self) -> None: + """Unsubscribe to webhooks.""" + current_webhooks = await self._client.async_notify_list() - # Revoke subscriptions. - for profile in response.profiles: + for webhook_configuration in current_webhooks.profiles: LOGGER.debug( "Unsubscribing %s for %s in %s seconds", - profile.callbackurl, - profile.appli, - self._notify_unsubscribe_delay.total_seconds(), + webhook_configuration.callbackurl, + webhook_configuration.appli, + UNSUBSCRIBE_DELAY.total_seconds(), ) # Quick calls to Withings can result in the service returning errors. # Give them some time to cool down. - await asyncio.sleep(self._notify_subscribe_delay.total_seconds()) - await self._api.async_notify_revoke(profile.callbackurl, profile.appli) + await asyncio.sleep(UNSUBSCRIBE_DELAY.total_seconds()) + await self._client.async_notify_revoke( + webhook_configuration.callbackurl, webhook_configuration.appli + ) - async def async_get_all_data(self) -> dict[MeasureType, Any] | None: - """Update all withings data.""" + async def _async_update_data(self) -> dict[Measurement, Any]: try: - return { - **await self.async_get_measures(), - **await self.async_get_sleep_summary(), - } - except Exception as exception: - # User is not authenticated. - if isinstance( - exception, (UnauthorizedException, AuthFailedException) - ) or NOT_AUTHENTICATED_ERROR.match(str(exception)): - self._api.config_entry.async_start_reauth(self._hass) - return None - - raise exception - - async def async_get_measures(self) -> dict[Measurement, Any]: - """Get the measures data.""" + measurements = await self._get_measurements() + sleep_summary = await self._get_sleep_summary() + except (UnauthorizedException, AuthFailedException) as exc: + raise ConfigEntryAuthFailed from exc + return { + **measurements, + **sleep_summary, + } + + async def _get_measurements(self) -> dict[Measurement, Any]: LOGGER.debug("Updating withings measures") now = dt_util.utcnow() - startdate = now - datetime.timedelta(days=7) + startdate = now - timedelta(days=7) - response = await self._api.async_measure_get_meas( + response = await self._client.async_measure_get_meas( None, None, startdate, now, None, startdate ) @@ -334,17 +179,13 @@ async def async_get_measures(self) -> dict[Measurement, Any]: if measure.type in WITHINGS_MEASURE_TYPE_MAP } - async def async_get_sleep_summary(self) -> dict[Measurement, Any]: - """Get the sleep summary data.""" - LOGGER.debug("Updating withing sleep summary") + async def _get_sleep_summary(self) -> dict[Measurement, Any]: now = dt_util.now() - yesterday = now - datetime.timedelta(days=1) - yesterday_noon = dt_util.start_of_local_day(yesterday) + datetime.timedelta( - hours=12 - ) + yesterday = now - timedelta(days=1) + yesterday_noon = dt_util.start_of_local_day(yesterday) + timedelta(hours=12) yesterday_noon_utc = dt_util.as_utc(yesterday_noon) - response = await self._api.async_sleep_get_summary( + response = await self._client.async_sleep_get_summary( lastupdate=yesterday_noon_utc, data_fields=[ GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, @@ -415,81 +256,18 @@ def set_value(field: GetSleepSummaryField, func: Callable) -> None: for field, value in values.items() } - async def async_webhook_data_updated(self, data_category: NotifyAppli) -> None: - """Handle scenario when data is updated from a webook.""" + async def async_webhook_data_updated( + self, notification_category: NotifyAppli + ) -> None: + """Update data when webhook is called.""" LOGGER.debug("Withings webhook triggered") - if data_category in { + if notification_category in { NotifyAppli.WEIGHT, NotifyAppli.CIRCULATORY, NotifyAppli.SLEEP, }: - await self.poll_data_update_coordinator.async_request_refresh() - - elif data_category in {NotifyAppli.BED_IN, NotifyAppli.BED_OUT}: - self.webhook_update_coordinator.update_data( - Measurement.IN_BED, data_category == NotifyAppli.BED_IN - ) - - -async def async_get_data_manager( - hass: HomeAssistant, config_entry: ConfigEntry -) -> DataManager: - """Get the data manager for a config entry.""" - hass.data.setdefault(const.DOMAIN, {}) - hass.data[const.DOMAIN].setdefault(config_entry.entry_id, {}) - config_entry_data = hass.data[const.DOMAIN][config_entry.entry_id] - - if const.DATA_MANAGER not in config_entry_data: - LOGGER.debug( - "Creating withings data manager for profile: %s", config_entry.title - ) - config_entry_data[const.DATA_MANAGER] = DataManager( - hass, - ConfigEntryWithingsApi( - hass=hass, - config_entry=config_entry, - implementation=await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, config_entry - ), - ), - config_entry.data["token"]["userid"], - WebhookConfig( - id=config_entry.data[CONF_WEBHOOK_ID], - url=webhook.async_generate_url( - hass, config_entry.data[CONF_WEBHOOK_ID] - ), - enabled=config_entry.options[const.CONF_USE_WEBHOOK], - ), - ) - - return config_entry_data[const.DATA_MANAGER] - - -def get_data_manager_by_webhook_id( - hass: HomeAssistant, webhook_id: str -) -> DataManager | None: - """Get a data manager by it's webhook id.""" - return next( - iter( - [ - data_manager - for data_manager in get_all_data_managers(hass) - if data_manager.webhook_config.id == webhook_id - ] - ), - None, - ) - - -def get_all_data_managers(hass: HomeAssistant) -> tuple[DataManager, ...]: - """Get all configured data managers.""" - return tuple( - config_entry_data[const.DATA_MANAGER] - for config_entry_data in hass.data[const.DOMAIN].values() - if const.DATA_MANAGER in config_entry_data - ) - + await self.async_request_refresh() -def async_remove_data_manager(hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Remove a data manager for a config entry.""" - del hass.data[const.DOMAIN][config_entry.entry_id][const.DATA_MANAGER] + elif notification_category in {NotifyAppli.BED_IN, NotifyAppli.BED_OUT}: + self.in_bed = notification_category == NotifyAppli.BED_IN + self.async_update_listeners() diff --git a/homeassistant/components/withings/entity.py b/homeassistant/components/withings/entity.py index f17d3ccf03c54..855162c461637 100644 --- a/homeassistant/components/withings/entity.py +++ b/homeassistant/components/withings/entity.py @@ -2,15 +2,14 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any from withings_api.common import GetSleepSummaryField, MeasureType, NotifyAppli -from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .common import DataManager, UpdateType +from .common import WithingsDataUpdateCoordinator from .const import DOMAIN, Measurement @@ -20,7 +19,6 @@ class WithingsEntityDescriptionMixin: measurement: Measurement measure_type: NotifyAppli | GetSleepSummaryField | MeasureType - update_type: UpdateType @dataclass @@ -28,72 +26,22 @@ class WithingsEntityDescription(EntityDescription, WithingsEntityDescriptionMixi """Immutable class for describing withings data.""" -class BaseWithingsSensor(Entity): - """Base class for withings sensors.""" +class WithingsEntity(CoordinatorEntity[WithingsDataUpdateCoordinator]): + """Base class for withings entities.""" - _attr_should_poll = False entity_description: WithingsEntityDescription _attr_has_entity_name = True def __init__( - self, data_manager: DataManager, description: WithingsEntityDescription + self, + coordinator: WithingsDataUpdateCoordinator, + description: WithingsEntityDescription, ) -> None: - """Initialize the Withings sensor.""" - self._data_manager = data_manager + """Initialize the Withings entity.""" + super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = ( - f"withings_{data_manager.user_id}_{description.measurement.value}" - ) - self._state_data: Any | None = None + self._attr_unique_id = f"withings_{coordinator.config_entry.unique_id}_{description.measurement.value}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(data_manager.user_id))}, manufacturer="Withings" - ) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - if self.entity_description.update_type == UpdateType.POLL: - return self._data_manager.poll_data_update_coordinator.last_update_success - - if self.entity_description.update_type == UpdateType.WEBHOOK: - return self._data_manager.webhook_config.enabled and ( - self.entity_description.measurement - in self._data_manager.webhook_update_coordinator.data - ) - - return True - - @callback - def _on_poll_data_updated(self) -> None: - self._update_state_data( - self._data_manager.poll_data_update_coordinator.data or {} + identifiers={(DOMAIN, str(coordinator.config_entry.unique_id))}, + manufacturer="Withings", ) - - @callback - def _on_webhook_data_updated(self) -> None: - self._update_state_data( - self._data_manager.webhook_update_coordinator.data or {} - ) - - def _update_state_data(self, data: dict[Measurement, Any]) -> None: - """Update the state data.""" - self._state_data = data.get(self.entity_description.measurement) - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Register update dispatcher.""" - if self.entity_description.update_type == UpdateType.POLL: - self.async_on_remove( - self._data_manager.poll_data_update_coordinator.async_add_listener( - self._on_poll_data_updated - ) - ) - self._on_poll_data_updated() - - elif self.entity_description.update_type == UpdateType.WEBHOOK: - self.async_on_remove( - self._data_manager.webhook_update_coordinator.async_add_listener( - self._on_webhook_data_updated - ) - ) - self._on_webhook_data_updated() diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index e8798adae2f05..7b867ad0cff1b 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -23,8 +23,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import UpdateType, async_get_data_manager +from .common import WithingsDataUpdateCoordinator from .const import ( + DOMAIN, SCORE_POINTS, UOM_BEATS_PER_MINUTE, UOM_BREATHS_PER_MINUTE, @@ -32,7 +33,7 @@ UOM_MMHG, Measurement, ) -from .entity import BaseWithingsSensor, WithingsEntityDescription +from .entity import WithingsEntity, WithingsEntityDescription @dataclass @@ -50,7 +51,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.FAT_MASS_KG.value, @@ -60,7 +60,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.FAT_FREE_MASS_KG.value, @@ -70,7 +69,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.MUSCLE_MASS_KG.value, @@ -80,7 +78,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.BONE_MASS_KG.value, @@ -90,7 +87,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.HEIGHT_M.value, @@ -101,7 +97,6 @@ class WithingsSensorEntityDescription( device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.TEMP_C.value, @@ -110,7 +105,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.BODY_TEMP_C.value, @@ -120,7 +114,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SKIN_TEMP_C.value, @@ -130,7 +123,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.FAT_RATIO_PCT.value, @@ -139,7 +131,6 @@ class WithingsSensorEntityDescription( translation_key="fat_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.DIASTOLIC_MMHG.value, @@ -148,7 +139,6 @@ class WithingsSensorEntityDescription( translation_key="diastolic_blood_pressure", native_unit_of_measurement=UOM_MMHG, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SYSTOLIC_MMGH.value, @@ -157,7 +147,6 @@ class WithingsSensorEntityDescription( translation_key="systolic_blood_pressure", native_unit_of_measurement=UOM_MMHG, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.HEART_PULSE_BPM.value, @@ -167,7 +156,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SPO2_PCT.value, @@ -176,7 +164,6 @@ class WithingsSensorEntityDescription( translation_key="spo2", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.HYDRATION.value, @@ -188,7 +175,6 @@ class WithingsSensorEntityDescription( icon="mdi:water", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.PWV.value, @@ -198,7 +184,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, device_class=SensorDeviceClass.SPEED, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY.value, @@ -207,7 +192,6 @@ class WithingsSensorEntityDescription( translation_key="breathing_disturbances_intensity", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_DEEP_DURATION_SECONDS.value, @@ -219,7 +203,6 @@ class WithingsSensorEntityDescription( device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_TOSLEEP_DURATION_SECONDS.value, @@ -231,7 +214,6 @@ class WithingsSensorEntityDescription( device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS.value, @@ -243,7 +225,6 @@ class WithingsSensorEntityDescription( device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_HEART_RATE_AVERAGE.value, @@ -254,7 +235,6 @@ class WithingsSensorEntityDescription( icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_HEART_RATE_MAX.value, @@ -266,7 +246,6 @@ class WithingsSensorEntityDescription( icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_HEART_RATE_MIN.value, @@ -277,7 +256,6 @@ class WithingsSensorEntityDescription( icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_LIGHT_DURATION_SECONDS.value, @@ -289,7 +267,6 @@ class WithingsSensorEntityDescription( device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_REM_DURATION_SECONDS.value, @@ -301,7 +278,6 @@ class WithingsSensorEntityDescription( device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE.value, @@ -311,7 +287,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_RESPIRATORY_RATE_MAX.value, @@ -321,7 +296,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_RESPIRATORY_RATE_MIN.value, @@ -331,7 +305,6 @@ class WithingsSensorEntityDescription( native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_SCORE.value, @@ -342,7 +315,6 @@ class WithingsSensorEntityDescription( icon="mdi:medal", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_SNORING.value, @@ -351,7 +323,6 @@ class WithingsSensorEntityDescription( translation_key="snoring", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_SNORING_EPISODE_COUNT.value, @@ -360,7 +331,6 @@ class WithingsSensorEntityDescription( translation_key="snoring_episode_count", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_WAKEUP_COUNT.value, @@ -371,7 +341,6 @@ class WithingsSensorEntityDescription( icon="mdi:sleep-off", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_WAKEUP_DURATION_SECONDS.value, @@ -383,7 +352,6 @@ class WithingsSensorEntityDescription( device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), ] @@ -394,14 +362,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - data_manager = await async_get_data_manager(hass, entry) + coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - entities = [WithingsHealthSensor(data_manager, attribute) for attribute in SENSORS] + async_add_entities(WithingsSensor(coordinator, attribute) for attribute in SENSORS) - async_add_entities(entities, True) - -class WithingsHealthSensor(BaseWithingsSensor, SensorEntity): +class WithingsSensor(WithingsEntity, SensorEntity): """Implementation of a Withings sensor.""" entity_description: WithingsSensorEntityDescription @@ -409,4 +375,12 @@ class WithingsHealthSensor(BaseWithingsSensor, SensorEntity): @property def native_value(self) -> None | str | int | float: """Return the state of the entity.""" - return self._state_data + return self.coordinator.data[self.entity_description.measurement] + + @property + def available(self) -> bool: + """Return if the sensor is available.""" + return ( + super().available + and self.entity_description.measurement in self.coordinator.data + ) diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index 4634a77a8daf6..e6fb24244d668 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -3,6 +3,8 @@ from typing import Any 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 @@ -21,7 +23,7 @@ class WebhookResponse: async def call_webhook( - hass: HomeAssistant, webhook_id: str, data: dict[str, Any], client + hass: HomeAssistant, webhook_id: str, data: dict[str, Any], client: TestClient ) -> WebhookResponse: """Call the webhook.""" webhook_url = async_generate_url(hass, webhook_id) @@ -34,7 +36,7 @@ async def call_webhook( # Wait for remaining tasks to complete. await hass.async_block_till_done() - data: dict[str, Any] = await resp.json() + data = await resp.json() resp.close() return WebhookResponse(message=data["message"], message_code=data["code"]) @@ -46,7 +48,7 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) await async_process_ha_core_config( hass, - {"internal_url": "http://example.local:8123"}, + {"external_url": "http://example.local:8123"}, ) await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/withings/common.py b/tests/components/withings/common.py deleted file mode 100644 index 7680b19e28901..0000000000000 --- a/tests/components/withings/common.py +++ /dev/null @@ -1,328 +0,0 @@ -"""Common data for for the withings component tests.""" -from __future__ import annotations - -from dataclasses import dataclass -from http import HTTPStatus -from unittest.mock import MagicMock -from urllib.parse import urlparse - -from aiohttp.test_utils import TestClient -import arrow -from withings_api.common import ( - MeasureGetMeasResponse, - NotifyAppli, - NotifyListResponse, - SleepGetSummaryResponse, - UserGetDeviceResponse, -) - -from homeassistant import data_entry_flow -import homeassistant.components.api as api -from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN -import homeassistant.components.webhook as webhook -from homeassistant.components.withings.common import ( - ConfigEntryWithingsApi, - DataManager, - get_all_data_managers, -) -import homeassistant.components.withings.const as const -from homeassistant.components.withings.entity import WithingsEntityDescription -from homeassistant.config import async_process_ha_core_config -from homeassistant.config_entries import SOURCE_USER, ConfigEntry -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_EXTERNAL_URL, - CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_METRIC, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow, entity_registry as er -from homeassistant.helpers.config_entry_oauth2_flow import AUTH_CALLBACK_PATH -from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util - -from tests.common import MockConfigEntry -from tests.components.withings import WebhookResponse -from tests.test_util.aiohttp import AiohttpClientMocker - - -@dataclass -class ProfileConfig: - """Data representing a user profile.""" - - profile: str - user_id: int - api_response_user_get_device: UserGetDeviceResponse | Exception - api_response_measure_get_meas: MeasureGetMeasResponse | Exception - api_response_sleep_get_summary: SleepGetSummaryResponse | Exception - api_response_notify_list: NotifyListResponse | Exception - api_response_notify_revoke: Exception | None - - -def new_profile_config( - profile: str, - user_id: int, - api_response_user_get_device: UserGetDeviceResponse | Exception | None = None, - api_response_measure_get_meas: MeasureGetMeasResponse | Exception | None = None, - api_response_sleep_get_summary: SleepGetSummaryResponse | Exception | None = None, - api_response_notify_list: NotifyListResponse | Exception | None = None, - api_response_notify_revoke: Exception | None = None, -) -> ProfileConfig: - """Create a new profile config immutable object.""" - return ProfileConfig( - profile=profile, - user_id=user_id, - api_response_user_get_device=api_response_user_get_device - or UserGetDeviceResponse(devices=[]), - api_response_measure_get_meas=api_response_measure_get_meas - or MeasureGetMeasResponse( - measuregrps=[], - more=False, - offset=0, - timezone=dt_util.UTC, - updatetime=arrow.get(12345), - ), - api_response_sleep_get_summary=api_response_sleep_get_summary - or SleepGetSummaryResponse(more=False, offset=0, series=[]), - api_response_notify_list=api_response_notify_list - or NotifyListResponse(profiles=[]), - api_response_notify_revoke=api_response_notify_revoke, - ) - - -class ComponentFactory: - """Manages the setup and unloading of the withing component and profiles.""" - - def __init__( - self, - hass: HomeAssistant, - api_class_mock: MagicMock, - hass_client_no_auth, - aioclient_mock: AiohttpClientMocker, - ) -> None: - """Initialize the object.""" - self._hass = hass - self._api_class_mock = api_class_mock - self._hass_client = hass_client_no_auth - self._aioclient_mock = aioclient_mock - self._client_id = None - self._client_secret = None - self._profile_configs: tuple[ProfileConfig, ...] = () - - async def configure_component( - self, - client_id: str = "my_client_id", - client_secret: str = "my_client_secret", - profile_configs: tuple[ProfileConfig, ...] = (), - ) -> None: - """Configure the wihings component.""" - self._client_id = client_id - self._client_secret = client_secret - self._profile_configs = profile_configs - - hass_config = { - "homeassistant": { - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, - CONF_EXTERNAL_URL: "http://127.0.0.1:8080/", - }, - api.DOMAIN: {}, - const.DOMAIN: { - CONF_CLIENT_ID: self._client_id, - CONF_CLIENT_SECRET: self._client_secret, - const.CONF_USE_WEBHOOK: True, - }, - } - - await async_process_ha_core_config(self._hass, hass_config.get("homeassistant")) - assert await async_setup_component(self._hass, HA_DOMAIN, {}) - assert await async_setup_component(self._hass, webhook.DOMAIN, hass_config) - - assert await async_setup_component(self._hass, const.DOMAIN, hass_config) - await self._hass.async_block_till_done() - - @staticmethod - def _setup_api_method(api_method, value) -> None: - if isinstance(value, Exception): - api_method.side_effect = value - else: - api_method.return_value = value - - async def setup_profile(self, user_id: int) -> ConfigEntryWithingsApi: - """Set up a user profile through config flows.""" - profile_config = next( - iter( - [ - profile_config - for profile_config in self._profile_configs - if profile_config.user_id == user_id - ] - ) - ) - - api_mock: ConfigEntryWithingsApi = MagicMock(spec=ConfigEntryWithingsApi) - api_mock.config_entry = MockConfigEntry( - domain=const.DOMAIN, - data={"profile": profile_config.profile}, - ) - ComponentFactory._setup_api_method( - api_mock.user_get_device, profile_config.api_response_user_get_device - ) - ComponentFactory._setup_api_method( - api_mock.sleep_get_summary, profile_config.api_response_sleep_get_summary - ) - ComponentFactory._setup_api_method( - api_mock.measure_get_meas, profile_config.api_response_measure_get_meas - ) - ComponentFactory._setup_api_method( - api_mock.notify_list, profile_config.api_response_notify_list - ) - ComponentFactory._setup_api_method( - api_mock.notify_revoke, profile_config.api_response_notify_revoke - ) - - self._api_class_mock.reset_mocks() - self._api_class_mock.return_value = api_mock - - # Get the withings config flow. - result = await self._hass.config_entries.flow.async_init( - const.DOMAIN, context={"source": SOURCE_USER} - ) - assert result - - state = config_entry_oauth2_flow._encode_jwt( - self._hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, - ) - assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP - assert result["url"] == ( - "https://account.withings.com/oauth2_user/authorize2?" - f"response_type=code&client_id={self._client_id}&" - "redirect_uri=https://example.com/auth/external/callback&" - f"state={state}" - "&scope=user.info,user.metrics,user.activity,user.sleepevents" - ) - - # Simulate user being redirected from withings site. - client: TestClient = await self._hass_client() - resp = await client.get(f"{AUTH_CALLBACK_PATH}?code=abcd&state={state}") - assert resp.status == HTTPStatus.OK - assert resp.headers["content-type"] == "text/html; charset=utf-8" - - self._aioclient_mock.clear_requests() - self._aioclient_mock.post( - "https://wbsapi.withings.net/v2/oauth2", - json={ - "body": { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - "userid": profile_config.user_id, - }, - }, - ) - - # Present user with a list of profiles to choose from. - result = await self._hass.config_entries.flow.async_configure(result["flow_id"]) - assert result.get("type") == "form" - assert result.get("step_id") == "profile" - assert "profile" in result.get("data_schema").schema - - # Provide the user profile. - result = await self._hass.config_entries.flow.async_configure( - result["flow_id"], {const.PROFILE: profile_config.profile} - ) - - # Finish the config flow by calling it again. - assert result.get("type") == "create_entry" - assert result.get("result") - config_data = result.get("result").data - assert config_data.get(const.PROFILE) == profile_config.profile - assert config_data.get("auth_implementation") == const.DOMAIN - assert config_data.get("token") - - # Wait for remaining tasks to complete. - await self._hass.async_block_till_done() - - # Mock the webhook. - data_manager = get_data_manager_by_user_id(self._hass, user_id) - self._aioclient_mock.clear_requests() - self._aioclient_mock.request( - "HEAD", - data_manager.webhook_config.url, - ) - - return self._api_class_mock.return_value - - async def call_webhook(self, user_id: int, appli: NotifyAppli) -> WebhookResponse: - """Call the webhook to notify of data changes.""" - client: TestClient = await self._hass_client() - data_manager = get_data_manager_by_user_id(self._hass, user_id) - - resp = await client.post( - urlparse(data_manager.webhook_config.url).path, - data={"userid": user_id, "appli": appli.value}, - ) - - # Wait for remaining tasks to complete. - await self._hass.async_block_till_done() - - data = await resp.json() - resp.close() - - return WebhookResponse(message=data["message"], message_code=data["code"]) - - async def unload(self, profile: ProfileConfig) -> None: - """Unload the component for a specific user.""" - config_entries = get_config_entries_for_user_id(self._hass, profile.user_id) - - for config_entry in config_entries: - await config_entry.async_unload(self._hass) - - await self._hass.async_block_till_done() - - assert not get_data_manager_by_user_id(self._hass, profile.user_id) - - -def get_config_entries_for_user_id( - hass: HomeAssistant, user_id: int -) -> tuple[ConfigEntry]: - """Get a list of config entries that apply to a specific withings user.""" - return tuple( - config_entry - for config_entry in hass.config_entries.async_entries(const.DOMAIN) - if config_entry.data.get("token", {}).get("userid") == user_id - ) - - -def get_data_manager_by_user_id( - hass: HomeAssistant, user_id: int -) -> DataManager | None: - """Get a data manager by the user id.""" - return next( - iter( - [ - data_manager - for data_manager in get_all_data_managers(hass) - if data_manager.user_id == user_id - ] - ), - None, - ) - - -async def async_get_entity_id( - hass: HomeAssistant, - description: WithingsEntityDescription, - user_id: int, - platform: str, -) -> str | None: - """Get an entity id for a user's attribute.""" - entity_registry = er.async_get(hass) - unique_id = f"withings_{user_id}_{description.measurement.value}" - - return entity_registry.async_get_entity_id(platform, const.DOMAIN, unique_id) diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index f1df0e3a65a33..60125a35feded 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -20,10 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .common import ComponentFactory - from tests.common import MockConfigEntry, load_json_object_fixture -from tests.test_util.aiohttp import AiohttpClientMocker CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -38,22 +35,6 @@ WEBHOOK_ID = "55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" -@pytest.fixture -def component_factory( - hass: HomeAssistant, - hass_client_no_auth, - aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, -): - """Return a factory for initializing the withings component.""" - with patch( - "homeassistant.components.withings.common.ConfigEntryWithingsApi" - ) as api_class_mock: - yield ComponentFactory( - hass, api_class_mock, hass_client_no_auth, aioclient_mock - ) - - @pytest.fixture(name="scopes") def mock_scopes() -> list[str]: """Fixture to set the scopes present in the OAuth token.""" @@ -78,8 +59,8 @@ def mock_expires_at() -> int: return time.time() + 3600 -@pytest.fixture(name="config_entry") -def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: +@pytest.fixture +def webhook_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: """Create Withings entry in Home Assistant.""" return MockConfigEntry( domain=DOMAIN, @@ -104,6 +85,32 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: ) +@pytest.fixture +def polling_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, + }, + options={ + "use_webhook": False, + }, + ) + + @pytest.fixture(name="withings") def mock_withings(): """Mock withings.""" @@ -123,7 +130,7 @@ def mock_withings(): ) with patch( - "homeassistant.components.withings.common.ConfigEntryWithingsApi", + "homeassistant.components.withings.ConfigEntryWithingsApi", return_value=mock, ): yield mock @@ -135,7 +142,8 @@ def disable_webhook_delay(): mock = AsyncMock() with patch( - "homeassistant.components.withings.common.SUBSCRIBE_DELAY", timedelta(seconds=0) + "homeassistant.components.withings.common.SUBSCRIBE_DELAY", + timedelta(seconds=0), ), patch( "homeassistant.components.withings.common.UNSUBSCRIBE_DELAY", timedelta(seconds=0), diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py index dca9fbc6437c2..8e641925d607e 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -1,9 +1,11 @@ """Tests for the Withings component.""" from unittest.mock import AsyncMock +from aiohttp.client_exceptions import ClientResponseError +import pytest from withings_api.common import NotifyAppli -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant from . import call_webhook, enable_webhooks, setup_integration @@ -17,18 +19,18 @@ async def test_binary_sensor( hass: HomeAssistant, withings: AsyncMock, disable_webhook_delay, - config_entry: MockConfigEntry, + webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test binary sensor.""" await enable_webhooks(hass) - await setup_integration(hass, config_entry) + await setup_integration(hass, webhook_config_entry) client = await hass_client_no_auth() entity_id = "binary_sensor.henk_in_bed" - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert hass.states.get(entity_id).state == STATE_UNKNOWN resp = await call_webhook( hass, @@ -49,3 +51,28 @@ async def test_binary_sensor( assert resp.message_code == 0 await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_OFF + + +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) + + client = await hass_client_no_auth() + + entity_id = "binary_sensor.henk_in_bed" + + assert hass.states.get(entity_id) is None + + with pytest.raises(ClientResponseError): + await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.BED_IN}, + client, + ) diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index d5745ae9bedf0..1fc26824d45a0 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -83,12 +83,12 @@ async def test_config_non_unique_profile( hass_client_no_auth: ClientSessionGenerator, current_request_with_host: None, withings: AsyncMock, - config_entry: MockConfigEntry, + polling_config_entry: MockConfigEntry, disable_webhook_delay, aioclient_mock: AiohttpClientMocker, ) -> None: """Test setup a non-unique profile.""" - await setup_integration(hass, config_entry) + await setup_integration(hass, polling_config_entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -136,21 +136,21 @@ async def test_config_reauth_profile( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - config_entry: MockConfigEntry, + polling_config_entry: MockConfigEntry, withings: AsyncMock, disable_webhook_delay, current_request_with_host, ) -> None: """Test reauth an existing profile reauthenticates the config entry.""" - await setup_integration(hass, config_entry) + await setup_integration(hass, polling_config_entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": SOURCE_REAUTH, - "entry_id": config_entry.entry_id, + "entry_id": polling_config_entry.entry_id, }, - data=config_entry.data, + data=polling_config_entry.data, ) assert result["type"] == "form" assert result["step_id"] == "reauth_confirm" @@ -199,21 +199,21 @@ async def test_config_reauth_wrong_account( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - config_entry: MockConfigEntry, + polling_config_entry: MockConfigEntry, withings: AsyncMock, disable_webhook_delay, current_request_with_host, ) -> None: """Test reauth with wrong account.""" - await setup_integration(hass, config_entry) + await setup_integration(hass, polling_config_entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": SOURCE_REAUTH, - "entry_id": config_entry.entry_id, + "entry_id": polling_config_entry.entry_id, }, - data=config_entry.data, + data=polling_config_entry.data, ) assert result["type"] == "form" assert result["step_id"] == "reauth_confirm" @@ -262,15 +262,15 @@ async def test_options_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - config_entry: MockConfigEntry, + polling_config_entry: MockConfigEntry, withings: AsyncMock, disable_webhook_delay, current_request_with_host, ) -> None: """Test options flow.""" - await setup_integration(hass, config_entry) + await setup_integration(hass, polling_config_entry) - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_init(polling_config_entry.entry_id) await hass.async_block_till_done() assert result["type"] == FlowResultType.FORM diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 15f0fff808d88..bae6df37126bb 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -4,18 +4,20 @@ from unittest.mock import AsyncMock, MagicMock from urllib.parse import urlparse +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol -from withings_api.common import NotifyAppli +from withings_api.common import AuthFailedException, NotifyAppli, UnauthorizedException +from homeassistant import config_entries from homeassistant.components.webhook import async_generate_url from homeassistant.components.withings import CONFIG_SCHEMA, DOMAIN, async_setup, const from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from . import enable_webhooks, setup_integration -from .conftest import WEBHOOK_ID +from . import call_webhook, enable_webhooks, setup_integration +from .conftest import USER_ID, WEBHOOK_ID from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator @@ -106,12 +108,12 @@ async def test_data_manager_webhook_subscription( hass: HomeAssistant, withings: AsyncMock, disable_webhook_delay, - config_entry: MockConfigEntry, + webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test data manager webhook subscriptions.""" await enable_webhooks(hass) - await setup_integration(hass, config_entry) + await setup_integration(hass, webhook_config_entry) await hass_client_no_auth() await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) @@ -132,6 +134,27 @@ async def test_data_manager_webhook_subscription( withings.async_notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_OUT) +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, +) -> None: + """Test webhook subscriptions not run when polling.""" + await setup_integration(hass, polling_config_entry) + await hass_client_no_auth() + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert withings.notify_revoke.call_count == 0 + assert withings.notify_subscribe.call_count == 0 + assert withings.notify_list.call_count == 0 + + @pytest.mark.parametrize( "method", [ @@ -142,13 +165,14 @@ async def test_data_manager_webhook_subscription( async def test_requests( hass: HomeAssistant, withings: AsyncMock, - config_entry: MockConfigEntry, + webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, method: str, disable_webhook_delay, ) -> None: """Test we handle request methods Withings sends.""" - await setup_integration(hass, config_entry) + 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) @@ -159,6 +183,59 @@ async def test_requests( assert response.status == 200 +async def test_webhooks_request_data( + hass: HomeAssistant, + 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() + + assert withings.async_measure_get_meas.call_count == 1 + + 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", + [ + UnauthorizedException(401), + AuthFailedException(500), + ], +) +async def test_triggering_reauth( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + error: Exception, +) -> None: + """Test triggering reauth.""" + await setup_integration(hass, polling_config_entry) + + withings.async_measure_get_meas.side_effect = error + future = dt_util.utcnow() + timedelta(minutes=10) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + + assert len(flows) == 1 + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert flow["context"]["source"] == config_entries.SOURCE_REAUTH + + @pytest.mark.parametrize( ("config_entry"), [ @@ -220,7 +297,7 @@ async def test_config_flow_upgrade( async def test_webhook_post( hass: HomeAssistant, withings: AsyncMock, - config_entry: MockConfigEntry, + webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, disable_webhook_delay, body: dict[str, Any], @@ -228,7 +305,8 @@ async def test_webhook_post( current_request_with_host: None, ) -> None: """Test webhook callback.""" - await setup_integration(hass, config_entry) + 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 cf0069c968a6f..b0df6e4c3c249 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -1,24 +1,26 @@ """Tests for the Withings component.""" +from datetime import timedelta from typing import Any from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion from withings_api.common import NotifyAppli from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.withings.const import Measurement +from homeassistant.components.withings.const import DOMAIN, Measurement from homeassistant.components.withings.entity import WithingsEntityDescription from homeassistant.components.withings.sensor import SENSORS +from homeassistant.const import STATE_UNAVAILABLE 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, setup_integration -from .common import async_get_entity_id +from . import call_webhook, enable_webhooks, setup_integration from .conftest import USER_ID, WEBHOOK_ID -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator WITHINGS_MEASUREMENTS_MAP: dict[Measurement, WithingsEntityDescription] = { @@ -60,6 +62,19 @@ ) +async def async_get_entity_id( + hass: HomeAssistant, + description: WithingsEntityDescription, + user_id: int, + platform: str, +) -> str | None: + """Get an entity id for a user's attribute.""" + entity_registry = er.async_get(hass) + unique_id = f"withings_{user_id}_{description.measurement.value}" + + return entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) + + def async_assert_state_equals( entity_id: str, state_obj: State, @@ -79,12 +94,13 @@ def async_assert_state_equals( async def test_sensor_default_enabled_entities( hass: HomeAssistant, withings: AsyncMock, - config_entry: MockConfigEntry, + webhook_config_entry: MockConfigEntry, disable_webhook_delay, hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test entities enabled by default.""" - await setup_integration(hass, config_entry) + await enable_webhooks(hass) + await setup_integration(hass, webhook_config_entry) entity_registry: EntityRegistry = er.async_get(hass) client = await hass_client_no_auth() @@ -122,11 +138,31 @@ async def test_all_entities( snapshot: SnapshotAssertion, withings: AsyncMock, disable_webhook_delay, - config_entry: MockConfigEntry, + polling_config_entry: MockConfigEntry, ) -> None: """Test all entities.""" - await setup_integration(hass, config_entry) + await setup_integration(hass, polling_config_entry) for sensor in SENSORS: entity_id = await async_get_entity_id(hass, sensor, USER_ID, SENSOR_DOMAIN) assert hass.states.get(entity_id) == snapshot + + +async def test_update_failed( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test all entities.""" + await setup_integration(hass, polling_config_entry) + + withings.async_measure_get_meas.side_effect = Exception + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.henk_weight") + assert state is not None + assert state.state == STATE_UNAVAILABLE