"""Support for the Withings API. For more details about this platform, please refer to the documentation at """ import asyncio from collections.abc import Awaitable, Callable import contextlib from dataclasses import dataclass, field from datetime import timedelta from typing import TYPE_CHECKING, Any from aiohttp import ClientError from aiohttp.hdrs import METH_POST from aiohttp.web import Request, Response from aiowithings import NotificationCategory, WithingsClient from aiowithings.exceptions import ( WithingsAuthenticationFailedError, WithingsError, WithingsInvalidParamsError, WithingsTooManyRequestsError, WithingsUnauthorizedError, ) from aiowithings.util import to_enum from yarl import URL from homeassistant.components import cloud from homeassistant.components.http import HomeAssistantView from homeassistant.components.webhook import ( 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 ( CONF_ACCESS_TOKEN, CONF_TOKEN, CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP, Platform, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, OAuth2Session, async_get_config_entry_implementation, ) from homeassistant.helpers.event import async_call_later from .const import DEFAULT_TITLE, DOMAIN, LOGGER from .coordinator import ( WithingsActivityDataUpdateCoordinator, WithingsBedPresenceDataUpdateCoordinator, WithingsDataUpdateCoordinator, WithingsDeviceDataUpdateCoordinator, WithingsGoalsDataUpdateCoordinator, WithingsMeasurementDataUpdateCoordinator, WithingsSleepDataUpdateCoordinator, WithingsWorkoutDataUpdateCoordinator, ) PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR] SUBSCRIBE_DELAY = timedelta(seconds=5) UNSUBSCRIBE_DELAY = timedelta(seconds=1) WEBHOOK_REGISTER_DELAY = 1 MAX_WEBHOOK_RETRY_INTERVAL = 1800 CONF_CLOUDHOOK_URL = "cloudhook_url" type WithingsConfigEntry = ConfigEntry[WithingsData] @dataclass(slots=True) class WithingsData: """Dataclass to hold withings domain data.""" client: WithingsClient measurement_coordinator: WithingsMeasurementDataUpdateCoordinator sleep_coordinator: WithingsSleepDataUpdateCoordinator bed_presence_coordinator: WithingsBedPresenceDataUpdateCoordinator goals_coordinator: WithingsGoalsDataUpdateCoordinator activity_coordinator: WithingsActivityDataUpdateCoordinator workout_coordinator: WithingsWorkoutDataUpdateCoordinator device_coordinator: WithingsDeviceDataUpdateCoordinator coordinators: set[WithingsDataUpdateCoordinator] = field(default_factory=set) def __post_init__(self) -> None: """Collect all coordinators in a set.""" self.coordinators = { self.measurement_coordinator, self.sleep_coordinator, self.bed_presence_coordinator, self.goals_coordinator, self.activity_coordinator, self.workout_coordinator, self.device_coordinator, } async def async_setup_entry(hass: HomeAssistant, entry: WithingsConfigEntry) -> bool: """Set up Withings from a config entry.""" if CONF_WEBHOOK_ID not in entry.data or entry.unique_id is None: new_data = entry.data.copy() unique_id = str(entry.data[CONF_TOKEN]["userid"]) if CONF_WEBHOOK_ID not in new_data: new_data[CONF_WEBHOOK_ID] = webhook_generate_id() hass.config_entries.async_update_entry( entry, data=new_data, unique_id=unique_id ) session = async_get_clientsession(hass) client = WithingsClient(session=session) try: implementation = await async_get_config_entry_implementation(hass, entry) except ImplementationUnavailableError as err: raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="oauth2_implementation_unavailable", ) from err oauth_session = OAuth2Session(hass, entry, implementation) refresh_lock = asyncio.Lock() async def _refresh_token() -> str: async with refresh_lock: await oauth_session.async_ensure_token_valid() token = oauth_session.token[CONF_ACCESS_TOKEN] if TYPE_CHECKING: assert isinstance(token, str) return token client.refresh_token_function = _refresh_token withings_data = WithingsData( client=client, measurement_coordinator=WithingsMeasurementDataUpdateCoordinator( hass, entry, client ), sleep_coordinator=WithingsSleepDataUpdateCoordinator(hass, entry, client), bed_presence_coordinator=WithingsBedPresenceDataUpdateCoordinator( hass, entry, client ), goals_coordinator=WithingsGoalsDataUpdateCoordinator(hass, entry, client), activity_coordinator=WithingsActivityDataUpdateCoordinator(hass, entry, client), workout_coordinator=WithingsWorkoutDataUpdateCoordinator(hass, entry, client), device_coordinator=WithingsDeviceDataUpdateCoordinator(hass, entry, client), ) for coordinator in withings_data.coordinators: await coordinator.async_config_entry_first_refresh() device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, str(entry.unique_id))}, manufacturer="Withings", ) entry.runtime_data = withings_data webhook_manager = WithingsWebhookManager(hass, entry) async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: LOGGER.debug("Cloudconnection state changed to %s", state) if state is cloud.CloudConnectionState.CLOUD_CONNECTED: await webhook_manager.register_webhook(None) if state is cloud.CloudConnectionState.CLOUD_DISCONNECTED: await webhook_manager.unregister_webhook(None) entry.async_on_unload( async_call_later(hass, 30, webhook_manager.register_webhook) ) if cloud.async_active_subscription(hass): if cloud.async_is_connected(hass): entry.async_on_unload( async_call_later( hass, WEBHOOK_REGISTER_DELAY, webhook_manager.register_webhook ) ) entry.async_on_unload( cloud.async_listen_connection_change(hass, manage_cloudhook) ) else: entry.async_on_unload( async_call_later( hass, WEBHOOK_REGISTER_DELAY, webhook_manager.register_webhook ) ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: WithingsConfigEntry) -> bool: """Unload Withings config entry.""" webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_subscribe_webhooks(client: WithingsClient, webhook_url: str) -> None: """Subscribe to Withings webhooks.""" await async_unsubscribe_webhooks(client) notification_to_subscribe = { NotificationCategory.WEIGHT, NotificationCategory.PRESSURE, NotificationCategory.ACTIVITY, NotificationCategory.SLEEP, NotificationCategory.IN_BED, NotificationCategory.OUT_BED, } for notification in notification_to_subscribe: LOGGER.debug( "Subscribing %s for %s in %s seconds", 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 client.subscribe_notification(webhook_url, notification) class WithingsWebhookManager: """Manager that manages the Withings webhooks.""" def __init__(self, hass: HomeAssistant, entry: WithingsConfigEntry) -> None: """Initialize webhook manager.""" self.hass = hass self.entry = entry self._subscribe_attempt: int = 0 self._webhook_url: str | None = None self._webhooks_registered: bool = False self._webhook_url_invalid: bool = False self._ha_webhook_registered: bool = False self._cancel_retry: CALLBACK_TYPE | None = None self._register_lock = asyncio.Lock() @property def withings_data(self) -> WithingsData: """Return Withings data.""" return self.entry.runtime_data def _cancel_pending_retry(self) -> None: """Cancel any pending retry.""" if self._cancel_retry is not None: self._cancel_retry() self._cancel_retry = None def _schedule_retry(self, delay: int) -> None: """Schedule a webhook registration retry.""" self._cancel_pending_retry() self._cancel_retry = async_call_later( self.hass, delay, self._async_subscribe_webhook ) async def unregister_webhook( self, _: Any, ) -> None: """Unregister webhooks at Withings.""" async with self._register_lock: LOGGER.debug( "Unregister Withings webhook (%s)", self.entry.data[CONF_WEBHOOK_ID] ) self._cancel_pending_retry() webhook_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID]) self._ha_webhook_registered = False self._webhook_url = None for coordinator in self.withings_data.coordinators: coordinator.webhook_subscription_listener(False) self._webhooks_registered = False self._subscribe_attempt = 0 try: await async_unsubscribe_webhooks(self.withings_data.client) except WithingsError as ex: LOGGER.warning("Failed to unsubscribe from Withings webhook: %s", ex) async def register_webhook( self, _: Any, ) -> None: """Register webhooks at Withings.""" async with self._register_lock: if self._webhooks_registered: return self._cancel_pending_retry() if cloud.async_active_subscription(self.hass): webhook_url = await _async_cloudhook_generate_url(self.hass, self.entry) else: webhook_url = webhook_generate_url( self.hass, self.entry.data[CONF_WEBHOOK_ID] ) url = URL(webhook_url) if url.scheme != "https": if not self._webhook_url_invalid: LOGGER.warning( "Webhook not registered - HTTPS is required. " "See https://www.home-assistant.io/integrations/withings/#webhook-requirements" ) self._webhook_url_invalid = True return if url.port != 443: if not self._webhook_url_invalid: LOGGER.warning( "Webhook not registered - port 443 is required. " "See https://www.home-assistant.io/integrations/withings/#webhook-requirements" ) self._webhook_url_invalid = True return if not self._ha_webhook_registered: webhook_name = "Withings" if self.entry.title != DEFAULT_TITLE: webhook_name = f"{DEFAULT_TITLE} {self.entry.title}" webhook_register( self.hass, DOMAIN, webhook_name, self.entry.data[CONF_WEBHOOK_ID], get_webhook_handler(self.withings_data), allowed_methods=[METH_POST], ) self._ha_webhook_registered = True LOGGER.debug("Registered Withings webhook at hass: %s", webhook_url) self._webhook_url = webhook_url self._subscribe_attempt = 0 await self._async_subscribe_webhook() async def _async_subscribe_webhook(self, _: Any = None) -> None: """Attempt to subscribe to Withings webhooks.""" async with self._register_lock: if self._webhooks_registered or self._webhook_url is None: return try: await async_subscribe_webhooks( self.withings_data.client, self._webhook_url ) except ( WithingsUnauthorizedError, WithingsAuthenticationFailedError, ) as err: LOGGER.error( "Authentication failed while subscribing to webhooks: %s", err, ) self.entry.async_start_reauth(self.hass) return except WithingsInvalidParamsError as err: LOGGER.error( "Webhook URL rejected by Withings: %s", err, ) return except WithingsTooManyRequestsError as err: delay = min( 300 * (self._subscribe_attempt + 1), MAX_WEBHOOK_RETRY_INTERVAL ) LOGGER.warning( "Rate limited by Withings API (attempt %d): %s. " "Retrying in %d seconds", self._subscribe_attempt + 1, err, delay, ) except WithingsError as err: base_delay = 30 delay = min( base_delay * (2**self._subscribe_attempt), MAX_WEBHOOK_RETRY_INTERVAL, ) LOGGER.warning( "Failed to subscribe to Withings webhooks " "(attempt %d): %s. Retrying in %d seconds", self._subscribe_attempt + 1, err, delay, ) else: for coordinator in self.withings_data.coordinators: coordinator.webhook_subscription_listener(True) LOGGER.debug( "Registered Withings webhook at Withings: %s", self._webhook_url ) self.entry.async_on_unload( self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, self.unregister_webhook ) ) self._webhooks_registered = True return self._subscribe_attempt += 1 self._schedule_retry(delay) async def async_unsubscribe_webhooks(client: WithingsClient) -> None: """Unsubscribe to all Withings webhooks.""" try: current_webhooks = await client.list_notification_configurations() except ClientError: LOGGER.exception("Error when unsubscribing webhooks") return for webhook_configuration in current_webhooks: LOGGER.debug( "Unsubscribing %s for %s in %s seconds", webhook_configuration.callback_url, webhook_configuration.notification_category, 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(UNSUBSCRIBE_DELAY.total_seconds()) await client.revoke_notification_configurations( webhook_configuration.callback_url, webhook_configuration.notification_category, ) async def _async_cloudhook_generate_url( hass: HomeAssistant, entry: WithingsConfigEntry ) -> str: """Generate the full URL for a webhook_id.""" if CONF_CLOUDHOOK_URL not in entry.data: webhook_id = entry.data[CONF_WEBHOOK_ID] # Some users already have their webhook as cloudhook. # We remove them to be sure we can create a new one. with contextlib.suppress(ValueError): await cloud.async_delete_cloudhook(hass, webhook_id) webhook_url = await cloud.async_create_cloudhook(hass, 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: WithingsConfigEntry) -> 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}) def get_webhook_handler( withings_data: WithingsData, ) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]: """Return webhook handler.""" async def async_webhook_handler( hass: HomeAssistant, webhook_id: str, request: Request ) -> Response | None: # Handle http post calls to the path. if not request.body_exists: return json_message_response("No request body", message_code=12) params = await request.post() if "appli" not in params: return json_message_response( "Parameter appli not provided", message_code=20 ) notification_category = to_enum( NotificationCategory, int(params.getone("appli")), # type: ignore[arg-type] NotificationCategory.UNKNOWN, ) for coordinator in withings_data.coordinators: if notification_category in coordinator.notification_categories: await coordinator.async_webhook_data_updated(notification_category) return json_message_response("Success", message_code=0) return async_webhook_handler